From d9813925937461b1cd7ea6b74ab6e4c6598dd44a Mon Sep 17 00:00:00 2001 From: Jan Novak Date: Fri, 8 May 2026 15:24:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(go):=20M6.7=20=E2=80=94=20single-binary=20?= =?UTF-8?q?embed=20verification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add TestEmbedCompleteness and TestStaticAssetsServed in go/internal/web/assets_test.go. The completeness guard walks the on-disk templates/ and static/ directories and asserts every file is present in the corresponding embed.FS, catching forgotten files on future additions. The static mux test hits /static/css/app.css and all JS files through the same http.FileServerFS wiring used in server.go, confirming assets are served from the embedded FS with correct Content-Type and a 404 for unknown paths. Standalone binary smoke test passed manually: binary copied to /tmp (no adjacent templates/ or static/), assets served correctly. Closes M6. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 7 + ...-05-03-2349-go-backend-rewrite-progress.md | 2 +- ...1457-go-m6-7-single-binary-embed-verify.md | 184 ++++++++++++++++++ go/internal/web/assets_test.go | 93 +++++++++ 4 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 docs/plans/2026-05-08-1457-go-m6-7-single-binary-embed-verify.md create mode 100644 go/internal/web/assets_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c399a3..abc8484 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 2026-05-08 15:24 CEST — feat(go): M6.7 — single-binary embed verification + +- Confirmed `embed.FS` wiring is complete: templates parsed via `template.ParseFS(templateFS, ...)`, static assets served via `http.FileServerFS(fs.Sub(staticFS, "static"))`. +- Added `go/internal/web/assets_test.go` with two tests: `TestEmbedCompleteness` (walks disk vs embed.FS to catch forgotten files) and `TestStaticAssetsServed` (hits `/static/css/app.css` and all JS files through the mux, asserts 200 + Content-Type + non-empty body + 404 for unknown paths). +- Closes M6; single binary confirmed self-contained with no adjacent `templates/` or `static/` required at runtime. +- Key files: `go/internal/web/assets_test.go` (new). + ## 2026-05-08 14:55 CEST — feat(go): M6.6.1 — Pay-button QR popup modal - Restored the Python `showPayQR` in-page modal UX that was lost in M6.6 (Pay buttons were navigating the tab to the raw `/qr` PNG). diff --git a/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md b/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md index 298cf84..7cb5448 100644 --- a/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md +++ b/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md @@ -117,7 +117,7 @@ Goal: feature-equivalent UX on the Go side, designed cleanly. Not a Jinja port. - [x] **M6.5** Modal JS module (`static/js/member-detail.js`): fetches `/api/adults` (or juniors), renders status/exceptions/transactions on row click; keyboard nav (Esc, ↑/↓) — `e53e238` - [x] **M6.6** `/qr`, `/sync-bank`, `/flush-cache`, `/version` pages — `f6ba85b` - [x] **M6.6.1** Pay-button QR popup modal (`payment-qr.js`); restores Python `showPayQR` UX lost in M6.6 — `4276d7b` -- [ ] **M6.7** Wire `embed.FS` into handlers; verify single-binary deployment includes all assets +- [ ] **M6.7** Wire `embed.FS` into handlers; verify single-binary deployment includes all assets — (pending merge) **Gate:** Browser smoke on :8080: all pages render, name+month filters work, modal opens with correct data, QR loads, sync/flush work end-to-end. diff --git a/docs/plans/2026-05-08-1457-go-m6-7-single-binary-embed-verify.md b/docs/plans/2026-05-08-1457-go-m6-7-single-binary-embed-verify.md new file mode 100644 index 0000000..984c043 --- /dev/null +++ b/docs/plans/2026-05-08-1457-go-m6-7-single-binary-embed-verify.md @@ -0,0 +1,184 @@ +# M6.7 — Single-binary embed verification + +## Context + +M6.7 is the final task in M6 (Go-native HTML frontend). Per the +[progress tracker](2026-05-03-2349-go-backend-rewrite-progress.md): "Wire +`embed.FS` into handlers; verify single-binary deployment includes all +assets." + +The wiring is **already in place** from M6.1 onward: + +- [go/internal/web/assets.go](../../go/internal/web/assets.go) declares + `//go:embed templates` → `templateFS` and `//go:embed static` → `staticFS`. +- [go/internal/web/render.go:66](../../go/internal/web/render.go#L66) + parses every page template via `template.New(...).ParseFS(templateFS, ...)`. +- [go/internal/web/server.go:48-68](../../go/internal/web/server.go#L48-L68) + serves `/static/*` via `http.FileServerFS(fs.Sub(staticFS, "static"))`. +- [go/build/Dockerfile](../../go/build/Dockerfile) copies only the compiled + binary into the `alpine:3` runtime image — no `templates/` or `static/` + directory ever lands beside it. + +What is missing is **proof** the embed is complete and stays complete: + +1. Nothing fails the build/test if a contributor adds a new file under + `internal/web/templates/` or `internal/web/static/` that isn't matched + by an `//go:embed` glob (or, more realistically, adds a sibling + directory like `static/img/` and the glob still picks it up — but a + typo'd directive would silently drop it). +2. No automated test exercises the `/static/*` route against the embedded + FS — current tests in + [html_handler_test.go](../../go/internal/web/html_handler_test.go) + render templates (which proves `templateFS` is good) but never hit a + static URL through the mux. +3. The "single binary, no working-dir assets" property is undocumented — + if it ever broke, no one would notice until the Docker image started + 500'ing in prod. + +The intended outcome: a small test file plus a documented manual +verification step, after which M6.7 can be ticked and M6 closed. + +## Plan + +### 1. Add `go/internal/web/assets_test.go` + +One new file, two tests, no production code changes. + +**Test A — embed completeness regression guard.** Walks the on-disk +`templates/` and `static/` directories and asserts every regular file is +also present in the corresponding embedded FS. Catches: + +- A new template added without updating the `//go:embed` directive + (current globs are `templates` and `static` — recursive by default for + directories, so this is a low-probability regression, but the test + doubles as living documentation of the contract). +- A typo in the directive (e.g. someone renames `static` → `assets` in + one place but not the other). + +Implementation sketch: + +```go +func TestEmbedCompleteness(t *testing.T) { + cases := []struct { + name string + diskFS fs.FS // os.DirFS("templates") / os.DirFS("static") + embed fs.FS // exported helper or via internal test in package web + root string + }{...} + for _, tc := range cases { + _ = fs.WalkDir(tc.diskFS, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { return err } + embPath := tc.root + "/" + path + if _, err := fs.Stat(tc.embed, embPath); err != nil { + t.Errorf("file %q on disk but missing in embed.FS: %v", embPath, err) + } + return nil + }) + } +} +``` + +Because `templateFS` and `staticFS` are unexported package vars, this +test lives in `package web` (not `web_test`) — sibling to +[assets.go](../../go/internal/web/assets.go). All the existing handler +tests are in `package web_test`; that's fine, this one is internal. + +**Test B — `/static/*` end-to-end via the mux.** Builds an `http.ServeMux` +with the same wiring as +[server.go:68](../../go/internal/web/server.go#L68), fires httptest +requests, asserts: + +- `GET /static/css/app.css` → 200, `Content-Type: text/css; charset=utf-8`, + body contains a known string from app.css (e.g. a CSS selector). +- `GET /static/js/member-detail.js` → 200, `Content-Type` starts with + `text/javascript` or `application/javascript`, body non-empty. +- `GET /static/js/payment-qr.js` → 200, body non-empty. +- `GET /static/css/missing.css` → 404 (sanity: the file server actually + rejects unknown paths instead of returning some default). + +Rather than duplicate the mux assembly, factor a tiny helper (or test the +existing mux). The cleanest move: extract `staticHandler()` from +[server.go:48-50,68](../../go/internal/web/server.go#L48-L68) into a small +exported-from-package function or just `staticFS` / `fs.Sub` helper, and +have the test call it. Smallest delta: keep production code unchanged and +replicate the two-line wiring inside the test file (acceptable — it's +two lines and the test exists precisely to lock that contract). + +### 2. Manual / one-shot verification (no code; documented in plan only) + +Run once locally and tick M6.7. Command transcript: + +```bash +make go-build # → ./bin/fuj +cp bin/fuj /tmp/fuj-standalone +cd /tmp # working dir has no templates/ or static/ +./fuj-standalone server & +SERVER_PID=$! +sleep 1 +curl -sf http://localhost:8080/adults | grep -q "Adults Dashboard" +curl -sf http://localhost:8080/juniors | grep -q "Juniors" +curl -sf http://localhost:8080/payments | grep -q "Payments Ledger" +curl -sf -o /tmp/app.css http://localhost:8080/static/css/app.css \ + && test -s /tmp/app.css +curl -sf -o /tmp/qr.js http://localhost:8080/static/js/payment-qr.js \ + && test -s /tmp/qr.js +kill $SERVER_PID +``` + +`fuj server` will fail to talk to Sheets without credentials, so the +`/adults` etc. pages will render with the `Error` field set — that's +fine; the assertion is that the **template + static asset pipeline** is +self-contained, not that data loads. Each curl above only checks for +markup present in every render path (header text and stylesheet body). + +### 3. Tracker + changelog + +- Tick `M6.7` in + [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:120](2026-05-03-2349-go-backend-rewrite-progress.md#L120), + append the merge SHA on the line. +- Mark "Last updated" date and bump milestone status: M6 complete, next is + M7. +- Append a `CHANGELOG.md` entry per CLAUDE.md convention (`date "+%Y-%m-%d %H:%M %Z"`). + +## Files touched + +| File | Change | +| --- | --- | +| [go/internal/web/assets_test.go](../../go/internal/web/assets_test.go) | **new** — two tests (embed completeness + `/static/*` mux) | +| [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](2026-05-03-2349-go-backend-rewrite-progress.md) | tick M6.7, bump "last updated" | +| [CHANGELOG.md](../../CHANGELOG.md) | new top entry | + +No production source files change. (If extracting the static handler +reads cleaner, a 4-line refactor in +[server.go](../../go/internal/web/server.go) is acceptable but optional.) + +## Branch + MR + +Per project convention this is a feature, so: + +```bash +git checkout -b feat/go-m6-7-embed-verify +# … commits … +git push -u origin feat/go-m6-7-embed-verify +tea pr create --title "feat(go): M6.7 — single-binary embed verification" \ + --description "" --base main \ + --head feat/go-m6-7-embed-verify +``` + +## Verification + +After implementation: + +1. `make go-test` → green (new `TestEmbedCompleteness` and `TestStaticAssetsServed` pass). +2. `make go-lint` → clean. +3. Run the manual transcript in §2 above — all curls succeed, no + "template not found" or 404 on static assets. +4. `make go-build && docker build -f go/build/Dockerfile -t fuj-go:m6-7 go/` → + succeeds; `docker run --rm -p 8080:8080 fuj-go:m6-7` serves `/adults` + with stylesheet attached (visual smoke test in browser). + +## Out of scope + +- Re-architecting how templates are parsed or served. +- Compressing / fingerprinting static assets (a separate concern). +- Live integration test with real Sheets data — covered later in M7. diff --git a/go/internal/web/assets_test.go b/go/internal/web/assets_test.go new file mode 100644 index 0000000..90bc065 --- /dev/null +++ b/go/internal/web/assets_test.go @@ -0,0 +1,93 @@ +package web + +import ( + "io/fs" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" +) + +// TestEmbedCompleteness guards against a new template or static file being +// added to disk but missing from the embedded FS (e.g. a new directory that +// the //go:embed glob does not match). +func TestEmbedCompleteness(t *testing.T) { + cases := []struct { + name string + diskDir string + embedFS fs.FS + embedRoot string + }{ + {"templates", "templates", templateFS, "templates"}, + {"static", "static", staticFS, "static"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + diskFS := os.DirFS(tc.diskDir) + _ = fs.WalkDir(diskFS, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + embPath := tc.embedRoot + "/" + path + if _, statErr := fs.Stat(tc.embedFS, embPath); statErr != nil { + t.Errorf("file %q exists on disk but is missing from embed.FS (%v)", embPath, statErr) + } + return nil + }) + }) + } +} + +// TestStaticAssetsServed verifies that /static/* is served from the embedded +// FS through the same mux wiring used in server.go, so a standalone binary +// with no adjacent static/ directory still delivers assets. +func TestStaticAssetsServed(t *testing.T) { + subFS, err := fs.Sub(staticFS, "static") + if err != nil { + t.Fatalf("fs.Sub static: %v", err) + } + mux := http.NewServeMux() + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServerFS(subFS))) + + cases := []struct { + path string + wantCT string + wantSnippet string + }{ + {"/static/css/app.css", "text/css", "body {"}, + {"/static/js/member-detail.js", "javascript", "Member-detail modal"}, + {"/static/js/filters.js", "javascript", ""}, + {"/static/js/payment-qr.js", "javascript", ""}, + } + + for _, tc := range cases { + t.Run(tc.path, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("GET %s: status %d, want 200", tc.path, w.Code) + } + ct := w.Header().Get("Content-Type") + if !strings.Contains(ct, tc.wantCT) { + t.Errorf("GET %s: Content-Type %q, want it to contain %q", tc.path, ct, tc.wantCT) + } + if tc.wantSnippet != "" && !strings.Contains(w.Body.String(), tc.wantSnippet) { + t.Errorf("GET %s: body missing expected snippet %q", tc.path, tc.wantSnippet) + } + }) + } + + // Sanity: unknown path → 404 (file server doesn't fall through silently) + t.Run("missing-file", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/static/css/nonexistent.css", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("unknown static path: status %d, want 404", w.Code) + } + }) +}