package web import ( "net" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "github.com/lukaszkasprzak/lectio/internal/config" "github.com/lukaszkasprzak/lectio/internal/liturgy" ) // TestServer exercises NewServer's handler tree end to end via httptest, // against the same fixture HTML/hook internal/readings uses (see // readings_test.go TestLoadModernRoutes): no real network, no real browser. func TestServer(t *testing.T) { html, err := os.ReadFile("../liturgy/testdata/2026-07-22.html") if err != nil { t.Fatal(err) } fixtureServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write(html) })) defer fixtureServer.Close() liturgy.SetBaseURL(fixtureServer.URL + "/liturgia/%s/Ewangelia") cacheHome := t.TempDir() t.Setenv("XDG_CACHE_HOME", cacheHome) 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); the // citation stays exactly as scraped. if !strings.Contains(body, "Gospel") { t.Errorf("body missing reading heading: %q", body) } if !strings.Contains(body, "htmx") { t.Errorf("body missing htmx reference") } if !strings.Contains(body, `id="theme"`) { t.Errorf("body missing theme ") } // Name is source-language (Polish), never translated, even // though the surrounding chrome is English -- see RenderReadings. if !strings.Contains(body, `class="dayinfo"`) || !strings.Contains(body, "Święto św. Marii Magdaleny") { 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) } body := rec.Body.String() if strings.Contains(body, "LEAKED-VIA-TRAVERSAL") { t.Fatalf("traversal date reached the planted cache file outside the cache dir: %q", body) } if body != baseline.Body.String() { t.Errorf("traversal date did not fall back to today() identically to omitting date\n got: %q\nwant: %q", body, 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()) } }) } // 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) { html, err := os.ReadFile("../liturgy/testdata/2026-07-22.html") if err != nil { t.Fatal(err) } fixtureServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write(html) })) defer fixtureServer.Close() liturgy.SetBaseURL(fixtureServer.URL + "/liturgia/%s/Ewangelia") t.Setenv("XDG_CACHE_HOME", t.TempDir()) 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) } }) }