Files
fuj-management/go/internal/web/html_handler_test.go
Jan Novak fe935235e8
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
feat(go): M6.6 — /qr, /sync-bank, /flush-cache, /version pages
- GET /qr: Czech QR Platba PNG; ports Python qr_code() exactly
  (account validation, amount clamping, * stripping, SPD format)
- GET /sync-bank: Fio sync → infer → cache flush with captured log
- GET+POST /flush-cache: form + action, shows deleted count
- GET /version: JSON alias of /api/version (Python parity)
- FlushCache() added to membership.Sources; wired through api.Handler
- web.ActionHandlers{BankSync} closure-based dep injection for sync
- New dep: github.com/skip2/go-qrcode
- TestQRBuildSPD (9 cases), TestServeQR, TestServeFlushCache{GET,POST},
  TestServeSync, TestServeVersion added

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:26:54 +02:00

338 lines
9.3 KiB
Go

package web_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"fuj-management/go/internal/config"
"fuj-management/go/internal/domain/reconcile"
"fuj-management/go/internal/web"
"fuj-management/go/internal/web/api"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// fixtureSources returns one adult ("Test Member", tier A) with a 2026-01 fee
// of 750 CZK (4 sessions) and a matching payment of 750 — mirrors Python's
// test_adults_route fixture.
type fixtureSources struct{}
func (fixtureSources) LoadAdults(_ context.Context) ([]reconcile.Member, []string, error) {
return []reconcile.Member{
{Name: "Test Member", Tier: "A", Fees: map[string]reconcile.FeeData{
"2026-01": {Expected: 750, Attendance: 4},
}},
}, []string{"2026-01"}, nil
}
func (fixtureSources) LoadJuniors(_ context.Context) ([]reconcile.Member, []string, error) {
return nil, nil, nil
}
func (fixtureSources) LoadTransactions(_ context.Context) ([]reconcile.Transaction, error) {
amt := float64(750)
return []reconcile.Transaction{
{Date: "2026-01-01", Amount: 750, Person: "Test Member", Purpose: "2026-01", InferredAmount: &amt},
}, nil
}
func (fixtureSources) LoadExceptions(_ context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error) {
return nil, nil
}
func (fixtureSources) FlushCache() (int, error) { return 0, nil }
func fixtureHandler(t *testing.T) *api.Handler {
t.Helper()
return &api.Handler{
Sources: fixtureSources{},
Config: config.Config{BankAccount: "CZ0000000000000000000000"},
}
}
func TestHTMLHandlerSmoke(t *testing.T) {
renderer, err := web.NewRenderer()
if err != nil {
t.Fatalf("NewRenderer: %v", err)
}
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
cases := []struct {
path string
handler http.HandlerFunc
}{
{"/adults", h.ServeAdults},
{"/juniors", h.ServeJuniors},
{"/payments", h.ServePayments},
{"/sync-bank", h.ServeSync},
{"/flush-cache", h.ServeFlushCacheGET},
}
for _, tc := range cases {
t.Run(tc.path, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
w := httptest.NewRecorder()
tc.handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Errorf("Content-Type = %q, want text/html", ct)
}
body := w.Body.String()
if n := strings.Count(body, `class="active"`); n != 1 {
t.Errorf(`class="active" count = %d, want 1`, n)
}
want := fmt.Sprintf(`href="%s" class="active"`, tc.path)
if !strings.Contains(body, want) {
t.Errorf("missing active link %q in body", want)
}
})
}
}
func TestAdultsPage(t *testing.T) {
renderer, err := web.NewRenderer()
if err != nil {
t.Fatalf("NewRenderer: %v", err)
}
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
req := httptest.NewRequest(http.MethodGet, "/adults", nil)
w := httptest.NewRecorder()
h.ServeAdults(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
body := w.Body.String()
for _, want := range []string{
"Adults Dashboard",
"Test Member",
"750/750 CZK (4)", // paid/expected (attendance)
} {
if !strings.Contains(body, want) {
t.Errorf("body missing %q", want)
}
}
// Python assertion: cell text never says literally "OK"
if strings.Contains(body, ">OK<") {
t.Error("body should not contain >OK<")
}
}
func TestModalMarkup(t *testing.T) {
renderer, err := web.NewRenderer()
if err != nil {
t.Fatalf("NewRenderer: %v", err)
}
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
cases := []struct {
path string
handler http.HandlerFunc
page string
wantRow string // data-name present only when the page has rows
}{
{"/adults", h.ServeAdults, "adults", `data-name="Test Member"`},
{"/juniors", h.ServeJuniors, "juniors", ""},
}
for _, tc := range cases {
t.Run(tc.path, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
w := httptest.NewRecorder()
tc.handler(w, req)
body := w.Body.String()
for _, want := range []string{
`data-page="` + tc.page + `"`,
`id="memberModal"`,
`/static/js/member-detail.js`,
} {
if !strings.Contains(body, want) {
t.Errorf("body missing %q", want)
}
}
if tc.wantRow != "" && !strings.Contains(body, tc.wantRow) {
t.Errorf("body missing info-icon row %q", tc.wantRow)
}
})
}
}
func TestPaymentsPage(t *testing.T) {
renderer, err := web.NewRenderer()
if err != nil {
t.Fatalf("NewRenderer: %v", err)
}
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
req := httptest.NewRequest(http.MethodGet, "/payments", nil)
w := httptest.NewRecorder()
h.ServePayments(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
body := w.Body.String()
for _, want := range []string{
"Payments Ledger",
"<h2>Test Member</h2>",
"750 CZK",
"2026-01-01",
"2026-01",
} {
if !strings.Contains(body, want) {
t.Errorf("body missing %q", want)
}
}
}
func TestServeQR(t *testing.T) {
renderer, err := web.NewRenderer()
if err != nil {
t.Fatalf("NewRenderer: %v", err)
}
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
req := httptest.NewRequest(http.MethodGet, "/qr?account=2702008874%2F2010&amount=700&message=Test", nil)
w := httptest.NewRecorder()
h.ServeQR(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if ct := w.Header().Get("Content-Type"); ct != "image/png" {
t.Errorf("Content-Type = %q, want image/png", ct)
}
// PNG magic bytes: \x89PNG\r\n\x1a\n
magic := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}
body := w.Body.Bytes()
if len(body) < len(magic) || !bytes.Equal(body[:len(magic)], magic) {
t.Error("response body does not start with PNG magic bytes")
}
}
func TestServeFlushCacheGET(t *testing.T) {
renderer, err := web.NewRenderer()
if err != nil {
t.Fatalf("NewRenderer: %v", err)
}
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
req := httptest.NewRequest(http.MethodGet, "/flush-cache", nil)
w := httptest.NewRecorder()
h.ServeFlushCacheGET(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "Flush Cache") {
t.Error("body missing Flush Cache heading")
}
if strings.Contains(body, "file(s) deleted") {
t.Error("GET should not show deleted count")
}
}
func TestServeFlushCachePOST(t *testing.T) {
renderer, err := web.NewRenderer()
if err != nil {
t.Fatalf("NewRenderer: %v", err)
}
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
req := httptest.NewRequest(http.MethodPost, "/flush-cache", nil)
w := httptest.NewRecorder()
h.ServeFlushCachePOST(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
body := w.Body.String()
// fixtureSources.FlushCache returns 0
if !strings.Contains(body, "file(s) deleted") {
t.Error("POST should show deleted count")
}
}
func TestServeSync(t *testing.T) {
renderer, err := web.NewRenderer()
if err != nil {
t.Fatalf("NewRenderer: %v", err)
}
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
actions := web.ActionHandlers{
BankSync: func(_ context.Context, out io.Writer) error {
fmt.Fprintln(out, "=== Sync Fio Transactions ===")
fmt.Fprintln(out, "Synced 3 new transaction(s).")
fmt.Fprintln(out, "=== Infer Payments ===")
fmt.Fprintln(out, "Inferred 2 row(s).")
return nil
},
}
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), actions)
req := httptest.NewRequest(http.MethodGet, "/sync-bank", nil)
w := httptest.NewRecorder()
h.ServeSync(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
body := w.Body.String()
for _, want := range []string{
"Sync Bank Data",
"Synced 3 new transaction(s).",
"Inferred 2 row(s).",
"Flush Cache",
} {
if !strings.Contains(body, want) {
t.Errorf("body missing %q", want)
}
}
}
func TestServeVersion(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/version", nil)
w := httptest.NewRecorder()
fixtureHandler(t).ServeVersion(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Errorf("Content-Type = %q, want application/json", ct)
}
var raw map[string]json.RawMessage
if err := json.NewDecoder(w.Body).Decode(&raw); err != nil {
t.Fatalf("decode JSON: %v", err)
}
for _, key := range []string{"tag", "commit", "build_date"} {
if _, ok := raw[key]; !ok {
t.Errorf("JSON response missing key %q", key)
}
}
}