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 <noreply@anthropic.com>
7.7 KiB
M6.7 — Single-binary embed verification
Context
M6.7 is the final task in M6 (Go-native HTML frontend). Per the
progress tracker: "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 declares
//go:embed templates→templateFSand//go:embed static→staticFS. - go/internal/web/render.go:66
parses every page template via
template.New(...).ParseFS(templateFS, ...). - go/internal/web/server.go:48-68
serves
/static/*viahttp.FileServerFS(fs.Sub(staticFS, "static")). - go/build/Dockerfile copies only the compiled
binary into the
alpine:3runtime image — notemplates/orstatic/directory ever lands beside it.
What is missing is proof the embed is complete and stays complete:
- Nothing fails the build/test if a contributor adds a new file under
internal/web/templates/orinternal/web/static/that isn't matched by an//go:embedglob (or, more realistically, adds a sibling directory likestatic/img/and the glob still picks it up — but a typo'd directive would silently drop it). - No automated test exercises the
/static/*route against the embedded FS — current tests in html_handler_test.go render templates (which provestemplateFSis good) but never hit a static URL through the mux. - 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:embeddirective (current globs aretemplatesandstatic— 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→assetsin one place but not the other).
Implementation sketch:
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. 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, 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-Typestarts withtext/javascriptorapplication/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 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:
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.7in docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:120, append the merge SHA on the line. - Mark "Last updated" date and bump milestone status: M6 complete, next is M7.
- Append a
CHANGELOG.mdentry per CLAUDE.md convention (date "+%Y-%m-%d %H:%M %Z").
Files touched
| File | Change |
|---|---|
| go/internal/web/assets_test.go | new — two tests (embed completeness + /static/* mux) |
| docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md | tick M6.7, bump "last updated" |
| CHANGELOG.md | new top entry |
No production source files change. (If extracting the static handler reads cleaner, a 4-line refactor in server.go is acceptable but optional.)
Branch + MR
Per project convention this is a feature, so:
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 "<short body referencing M6.7>" --base main \
--head feat/go-m6-7-embed-verify
Verification
After implementation:
make go-test→ green (newTestEmbedCompletenessandTestStaticAssetsServedpass).make go-lint→ clean.- Run the manual transcript in §2 above — all curls succeed, no "template not found" or 404 on static assets.
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-7serves/adultswith 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.