Compare commits
2 Commits
0.35
...
7f801d27f5
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f801d27f5 | |||
| d981392593 |
@@ -14,6 +14,12 @@
|
|||||||
- Fix: `scripts/fio_utils.py` `parse_czech_date` now accepts `DD.MM.YY` / `D.M.YY` in addition to the 4-digit-year variants. Fio's transparent page now mixes both forms in the same response; the 2-digit rows were being silently dropped, which caused `make sync-2026` to miss every recent transfer. Mirrors the Go-side fix from 2026-05-07 (CHANGELOG entry below).
|
- Fix: `scripts/fio_utils.py` `parse_czech_date` now accepts `DD.MM.YY` / `D.M.YY` in addition to the 4-digit-year variants. Fio's transparent page now mixes both forms in the same response; the 2-digit rows were being silently dropped, which caused `make sync-2026` to miss every recent transfer. Mirrors the Go-side fix from 2026-05-07 (CHANGELOG entry below).
|
||||||
- Added `--dry-run` and `--print-fio-table` flags to `scripts/sync_fio_to_sheets.py`, plus a `make sync-debug [DAYS=N]` Makefile target. Mirrors `make go-sync-debug`: fetches from Fio and dedupes against the sheet, prints `STATUS=NEW/DUP` per transaction, and prints per-row `Dry run: would append …` lines + `would sort by date` instead of touching the sheet.
|
- Added `--dry-run` and `--print-fio-table` flags to `scripts/sync_fio_to_sheets.py`, plus a `make sync-debug [DAYS=N]` Makefile target. Mirrors `make go-sync-debug`: fetches from Fio and dedupes against the sheet, prints `STATUS=NEW/DUP` per transaction, and prints per-row `Dry run: would append …` lines + `would sort by date` instead of touching the sheet.
|
||||||
- Added always-on stderr diagnostics in `scripts/fio_utils.py`: which fetcher was selected (authenticated API vs. transparent-page scraper with `FIO_API_TOKEN`-unset warning), and raw-vs-after-filter transaction counts on both paths — so this class of "scraper drops everything" bug surfaces immediately.
|
- Added always-on stderr diagnostics in `scripts/fio_utils.py`: which fetcher was selected (authenticated API vs. transparent-page scraper with `FIO_API_TOKEN`-unset warning), and raw-vs-after-filter transaction counts on both paths — so this class of "scraper drops everything" bug surfaces immediately.
|
||||||
|
## 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
|
## 2026-05-08 14:55 CEST — feat(go): M6.6.1 — Pay-button QR popup modal
|
||||||
|
|
||||||
|
|||||||
@@ -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.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** `/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`
|
- [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.
|
**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