Compare commits
2 Commits
4276d7b915
...
feat/go-m6
| Author | SHA1 | Date | |
|---|---|---|---|
| d981392593 | |||
| f25552eef2 |
16
CHANGELOG.md
16
CHANGELOG.md
@@ -1,5 +1,21 @@
|
||||
# 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).
|
||||
- Replaced `<a href="{{qrHref ...}}">Pay</a>` with `<button data-name|amount|month|raw-month>` on `/adults` and `/juniors`; click is handled by a new `static/js/payment-qr.js` IIFE module that opens `#qrModal` with title, account, amount, message, and the QR image.
|
||||
- Added `#qrModal` markup to both templates; CSS `display:none` / `.active{display:flex}` rules added (content rules were already present from M6.1). `Esc`, `[close]`, and outside-click all dismiss; coexists with the M6.5 member-detail modal.
|
||||
- Removed the now-dead `qrHref` / `qrHrefAll` template helpers from `render.go`.
|
||||
- Markup tests in `html_handler_test.go` assert modal IDs, script tag, `data-bank-account`, and that no bare `href="/qr"` links remain.
|
||||
- Key files: `go/internal/web/static/js/payment-qr.js`, `go/internal/web/templates/adults.tmpl`, `go/internal/web/templates/juniors.tmpl`, `go/internal/web/render.go`, `go/internal/web/static/css/app.css`.
|
||||
|
||||
## 2026-05-08 13:57 CEST — feat(go): M6.6 — /qr, /sync-bank, /flush-cache, /version
|
||||
|
||||
- Added `GET /qr`: generates Czech QR Platba PNG from SPD payload (account, amount, message query params); ports Python's `qr_code()` handler exactly including account validation, amount clamping, and `*` stripping.
|
||||
|
||||
@@ -4,7 +4,7 @@ Companion to [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-
|
||||
|
||||
**Current milestone:** M6 — Go-native HTML frontend
|
||||
**Started:** 2026-05-04
|
||||
**Last updated:** 2026-05-08 (M6.4 merged)
|
||||
**Last updated:** 2026-05-08 (M6.6 + M6.6.1 merged)
|
||||
|
||||
## How to use
|
||||
|
||||
@@ -115,8 +115,9 @@ Goal: feature-equivalent UX on the Go side, designed cleanly. Not a Jinja port.
|
||||
- [x] **M6.3** `/juniors` page: same structure + per-month J/A attendance breakdown + `"?"` sentinel rendering — `9564103`
|
||||
- [x] **M6.4** `/payments` page: grouped-by-person ledger view — `689f1c0`
|
||||
- [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
|
||||
- [ ] **M6.7** Wire `embed.FS` into handlers; verify single-binary deployment includes all assets
|
||||
- [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 — (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.
|
||||
|
||||
|
||||
184
docs/plans/2026-05-08-1457-go-m6-7-single-binary-embed-verify.md
Normal file
184
docs/plans/2026-05-08-1457-go-m6-7-single-binary-embed-verify.md
Normal file
@@ -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 "<short body referencing M6.7>" --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.
|
||||
93
go/internal/web/assets_test.go
Normal file
93
go/internal/web/assets_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user