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", "

Test Member

", "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 TestPaymentQRMarkup(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}, } 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{ `id="qrModal"`, `id="qrImg"`, `id="qrTitle"`, `id="qrAccount"`, `id="qrAmount"`, `id="qrMessage"`, `/static/js/payment-qr.js`, `data-bank-account="CZ0000000000000000000000"`, } { if !strings.Contains(body, want) { t.Errorf("body missing %q", want) } } // Pay buttons must use