summaryrefslogtreecommitdiff
path: root/internal/web/server_test.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-23 14:39:31 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-23 14:39:31 +0200
commit4de7f7dca7485c9a6c94f6f86a53d01a974fce54 (patch)
treebcdc1ea366a068310e460804c4a5f04df3ae20f4 /internal/web/server_test.go
parentb72b81c410c161797eb525a6231b0517b4def993 (diff)
downloadlectio-4de7f7dca7485c9a6c94f6f86a53d01a974fce54.tar.gz
lectio-4de7f7dca7485c9a6c94f6f86a53d01a974fce54.zip
web: HTMX server + lectio-web binary
NewServer wires B1's RenderReadings/Themes/themeCSS/embedded static+ templates FS into an http.ServeMux: GET / (full page), GET /readings (HTMX reading-pane fragment), GET /lookup (bible.Lookup passage search fragment), GET /theme.css (theme stylesheet, falling back through cfg.WebTheme to the built-in default on an unknown name), GET /static/. Run listens on cfg.WebPort (0 = OS-picked free port), prints the URL, best-effort opens a browser, then serves. cmd/lectio-web is the binary entry point (config.Load -> web.Run). Fold-in from the B1 review: hardened themeCSS's name guard to an explicit ^[A-Za-z0-9_-]+$ allowlist (the old filepath.Base/ContainsAny check let ".." through), plus guard-rejection and HTML-escaping regression tests -- B2 is what makes /theme.css?name=<raw> reachable from the network, so it owns closing this out.
Diffstat (limited to 'internal/web/server_test.go')
-rw-r--r--internal/web/server_test.go107
1 files changed, 107 insertions, 0 deletions
diff --git a/internal/web/server_test.go b/internal/web/server_test.go
new file mode 100644
index 0000000..9e37177
--- /dev/null
+++ b/internal/web/server_test.go
@@ -0,0 +1,107 @@
+package web
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "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")
+ t.Setenv("XDG_CACHE_HOME", t.TempDir())
+
+ 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()
+ if !strings.Contains(body, "Ewangelia") {
+ 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 <link>")
+ }
+ })
+
+ 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, "<html") {
+ t.Errorf("partial is not a fragment: %q", body)
+ }
+ if !strings.Contains(body, "Ewangelia") {
+ t.Errorf("partial missing reading heading: %q", body)
+ }
+ })
+
+ t.Run("lookup", func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/lookup?ref=J+20:1&v=wuj", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rec.Code)
+ }
+ if !strings.Contains(rec.Body.String(), "20:1") {
+ t.Errorf("lookup result missing verse: %q", rec.Body.String())
+ }
+ })
+
+ t.Run("theme.css", func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/theme.css?name=benedictines", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rec.Code)
+ }
+ if !strings.Contains(rec.Header().Get("Content-Type"), "css") {
+ t.Errorf("Content-Type = %q, want it to contain css", rec.Header().Get("Content-Type"))
+ }
+ })
+
+ t.Run("static", func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/static/htmx.min.js", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rec.Code)
+ }
+ if rec.Body.Len() == 0 {
+ t.Error("static/htmx.min.js served empty body")
+ }
+ })
+
+ t.Run("theme.css unknown falls back", func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/theme.css?name=does-not-exist", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200 (fallback to cfg.WebTheme/default)", rec.Code)
+ }
+ if rec.Body.Len() == 0 {
+ t.Error("theme.css fallback served empty body")
+ }
+ })
+}