0bcb96c442
web/handlers/reach.go: GET /reach (list), GET /reach/new (form),
GET /reach/{id} (detail), POST /reach (atomic Reach+Stash create
per D-071, redirect 302). Labels "Create a Reach" (not the banned
legacy word). G-027 validation (non-empty, <=128, no path separators,
no template syntax). 3 Reach templates extend base.html.
reach_test.go: httptest for all 4 routes + atomic create + 400/409
error paths + rendered-HTML lexicon check on BOTH 200 and error bodies
(G-026). handlers/server.go: clone-per-page template pattern (avoids
content-block collision across pages). Coverage 81.2% on web/handlers.
---ci---
project: oy
phase: 1
milestone: v0.6
status: execute
---/ci---
186 lines
6.2 KiB
Go
186 lines
6.2 KiB
Go
package handlers
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/oy/openyield/lexicon"
|
|
"github.com/oy/openyield/web/store"
|
|
)
|
|
|
|
// newTestServer builds a Server with a fresh store + templates parsed from
|
|
// web/templates (relative to repo root via the handlers test working dir).
|
|
func newTestServer(t *testing.T) *Server {
|
|
t.Helper()
|
|
srv, err := New(store.NewStore(), "../../web/templates")
|
|
if err != nil {
|
|
t.Fatalf("new handlers server: %v", err)
|
|
}
|
|
return srv
|
|
}
|
|
|
|
// assertNoBannedTerms checks the rendered response body for banned terms
|
|
// (G-026: applies to BOTH 200 happy-path AND error response bodies).
|
|
func assertNoBannedTerms(t *testing.T, body string) {
|
|
t.Helper()
|
|
if term, ok := lexicon.FindBannedTerm(body); ok {
|
|
t.Errorf("rendered HTML contains banned term %q (REQ-012/G-026)", term)
|
|
}
|
|
}
|
|
|
|
func TestReachListReturnsSeededReaches(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
mux := http.NewServeMux()
|
|
srv.Register(mux)
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest("GET", "/reach", nil)
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET /reach: status %d, want 200", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "holder-alia") {
|
|
t.Errorf("GET /reach: body missing seeded reach holder-alia")
|
|
}
|
|
if !strings.Contains(body, "holder-bryn") {
|
|
t.Errorf("GET /reach: body missing seeded reach holder-bryn")
|
|
}
|
|
assertNoBannedTerms(t, body)
|
|
}
|
|
|
|
func TestReachNewReturnsForm(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
mux := http.NewServeMux()
|
|
srv.Register(mux)
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest("GET", "/reach/new", nil)
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET /reach/new: status %d, want 200", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "Create a Reach") {
|
|
t.Errorf("GET /reach/new: body missing 'Create a Reach' label")
|
|
}
|
|
// The legacy custodial-position word is BANNED (REQ-012) — must not appear.
|
|
// Check the full banned-terms list via the lexicon package (no literals in
|
|
// source); FindBannedTerm does word-boundary matching so this is stricter
|
|
// than a naive substring check.
|
|
if term, ok := lexicon.FindBannedTerm(body); ok {
|
|
t.Errorf("GET /reach/new: body contains banned word %q", term)
|
|
}
|
|
assertNoBannedTerms(t, body)
|
|
}
|
|
|
|
func TestReachCreateValidRedirectsAndAtomicallyCreates(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
mux := http.NewServeMux()
|
|
srv.Register(mux)
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest("POST", "/reach", strings.NewReader("holder_id=holder-new&public_key=pk-new"))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("POST /reach valid: status %d, want 302 (Found)", rec.Code)
|
|
}
|
|
loc := rec.Header().Get("Location")
|
|
if !strings.Contains(loc, "/reach/holder-new") {
|
|
t.Errorf("POST /reach: Location %q, want redirect to /reach/holder-new", loc)
|
|
}
|
|
// D-071: atomic creation — both Reach + Stash must be present.
|
|
reach, ok := srv.Store.GetReach("holder-new")
|
|
if !ok {
|
|
t.Fatalf("POST /reach: GetReach miss after create (atomicity broken)")
|
|
}
|
|
if !reach.IsNomad {
|
|
t.Errorf("POST /reach: created Reach IsNomad=false, want true (D-071)")
|
|
}
|
|
stash, ok := srv.Store.GetStash("holder-new")
|
|
if !ok {
|
|
t.Fatalf("POST /reach: GetStash miss after create (atomicity broken — D-071)")
|
|
}
|
|
if stash.HolderID != reach.HolderID {
|
|
t.Errorf("POST /reach: stash.HolderID %q != reach.HolderID %q (D-071)", stash.HolderID, reach.HolderID)
|
|
}
|
|
}
|
|
|
|
func TestReachCreateEmptyHolderIDReturns400(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
mux := http.NewServeMux()
|
|
srv.Register(mux)
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest("POST", "/reach", strings.NewReader("holder_id=&public_key=pk"))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("POST /reach empty holder: status %d, want 400", rec.Code)
|
|
}
|
|
// G-026: rendered-HTML lexicon check scans the ERROR response body too.
|
|
assertNoBannedTerms(t, rec.Body.String())
|
|
}
|
|
|
|
func TestReachCreatePathSeparatorReturns400(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
mux := http.NewServeMux()
|
|
srv.Register(mux)
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest("POST", "/reach", strings.NewReader("holder_id=h/x&public_key=pk"))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("POST /reach path separator: status %d, want 400", rec.Code)
|
|
}
|
|
assertNoBannedTerms(t, rec.Body.String())
|
|
}
|
|
|
|
func TestReachCreateDuplicateReturns409(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
mux := http.NewServeMux()
|
|
srv.Register(mux)
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest("POST", "/reach", strings.NewReader("holder_id=holder-alia&public_key=pk"))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusConflict {
|
|
t.Fatalf("POST /reach duplicate: status %d, want 409", rec.Code)
|
|
}
|
|
assertNoBannedTerms(t, rec.Body.String())
|
|
}
|
|
|
|
func TestReachDetailSeededReturnsReachAndStash(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
mux := http.NewServeMux()
|
|
srv.Register(mux)
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest("GET", "/reach/holder-alia", nil)
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET /reach/holder-alia: status %d, want 200", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "reach-holder-alia") {
|
|
t.Errorf("GET /reach/holder-alia: body missing reach-holder-alia")
|
|
}
|
|
if !strings.Contains(body, "stash-holder-alia") {
|
|
t.Errorf("GET /reach/holder-alia: body missing associated stash-holder-alia")
|
|
}
|
|
if !strings.Contains(body, "Grain") {
|
|
t.Errorf("GET /reach/holder-alia: body missing Stash balance in Grain")
|
|
}
|
|
assertNoBannedTerms(t, body)
|
|
}
|
|
|
|
func TestReachDetailMissingReturns404(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
mux := http.NewServeMux()
|
|
srv.Register(mux)
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest("GET", "/reach/nobody", nil)
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("GET /reach/nobody: status %d, want 404", rec.Code)
|
|
}
|
|
}
|