Files
member-console/internal/server/forms_routes_test.go
T
cgalo5758 88db730fcc Add dual licensing and SPDX headers
Introduce a commercial license option alongside AGPL-3.0-only, require a
CLA for contributors, and document the terms in COMMERCIAL.md and
NOTICE. Add a script to stamp SPDX headers on Go files and apply it
across the tree.
2026-09-06 02:29:42 -05:00

140 lines
5.0 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
import (
"path/filepath"
"sort"
"strings"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/forms"
"git.coopcloud.tech/wiki-cafe/member-console/internal/lint"
)
// The registry's two checks (design D7; spec form-library "The registry
// holds every form and is checked"). The first holds every registered
// declaration to the library's invariants. The second walks the router's
// mutation routes and asserts each is either a registered form's path or a
// declared action trigger, which is the route-level
// template-versus-handler check no markup lint can do (findings FA-6,
// FA-7, FA-9). The screens manifest is the precedent for the shape: one
// list, read by a test, that a new route must join.
// pendingFormRoutes are the mutation routes still awaiting their
// declaration, each naming the lane of the forms-library change that
// removes it. The list is transient: it must be empty before the change
// archives (proposal, "A transient allowlist inside this change"). A route
// that is not here, not a registered form's path and not a declared
// trigger fails the test, so the list can only shrink.
var pendingFormRoutes = map[string]string{}
// routeScanDir is the tree whose mux registrations make up the router:
// internal/, which holds the console's own handlers and every integration
// that registers routes of its own.
const routeScanDir = ".."
// isConsoleRoute reports whether a registered route belongs to the
// console's router. Two kinds do not: the JSON API, which is not a surface
// a person fills in and so has no form to declare, and the routes a test
// double registers (the fake Discourse server in discoursetest, whose
// package name ends in "test"), which stand in for someone else's service.
func isConsoleRoute(r lint.Route) bool {
if strings.HasPrefix(r.Path, "/api/") {
return false
}
return !strings.HasSuffix(filepath.Base(filepath.Dir(r.File)), "test")
}
// TestRegisteredFormsSatisfyTheInvariants holds every declaration in the
// registry to the library's rules. It runs here rather than in
// internal/forms because a form registers only once its package is linked
// in, and this package is where the console's forms are declared.
func TestRegisteredFormsSatisfyTheInvariants(t *testing.T) {
all := forms.All()
if len(all) == 0 {
t.Fatal("no forms are registered; the declarations register as their package initialises")
}
for _, spec := range all {
for _, problem := range forms.CheckInvariants(spec) {
t.Errorf("%s: %s", spec.Name, problem)
}
}
}
// TestEveryMutationRouteIsDeclared walks the router's mutation routes and
// accounts for each one.
func TestEveryMutationRouteIsDeclared(t *testing.T) {
formRoutes := forms.Routes()
triggers := map[string]forms.ActionTrigger{}
for _, tr := range forms.ActionTriggers() {
triggers[tr.Key()] = tr
}
routes, err := lint.Routes(routeScanDir)
if err != nil {
t.Fatalf("scan %s: %v", routeScanDir, err)
}
seen := map[string]bool{}
for _, r := range routes {
if !r.IsMutation() || !isConsoleRoute(r) {
continue
}
key := r.Method + " " + r.Path
if seen[key] {
continue
}
seen[key] = true
if _, ok := formRoutes[key]; ok {
continue
}
if _, ok := triggers[key]; ok {
continue
}
if lane, ok := pendingFormRoutes[key]; ok {
t.Logf("pending: %s (%s)", key, lane)
continue
}
t.Errorf("mutation route %s (%s:%d) is neither a registered form's path nor a declared action trigger; declare the form beside its handler, or add it to forms.RegisterTrigger", key, r.File, r.Line)
}
// The pending list only shrinks: an entry whose route no longer exists
// is stale and must go, the same discipline the anatomy allowlist has.
var stale []string
for key := range pendingFormRoutes {
if !seen[key] {
stale = append(stale, key)
}
}
sort.Strings(stale)
for _, key := range stale {
t.Errorf("pendingFormRoutes lists %q, which the router no longer registers; remove the entry", key)
}
// Same for the declared triggers: a trigger whose route is gone is a
// declaration of nothing.
for _, tr := range forms.ActionTriggers() {
if !seen[tr.Key()] {
t.Errorf("the action trigger %q declares %q, which the router does not register", tr.Label, tr.Key())
}
}
}
// TestPendingRoutesAndFormsDoNotOverlap pins that a route leaves the
// pending list when its declaration lands, so the two lists cannot both
// claim one route and hide a lane's remaining work.
func TestPendingRoutesAndFormsDoNotOverlap(t *testing.T) {
formRoutes := forms.Routes()
for key, lane := range pendingFormRoutes {
if name, ok := formRoutes[key]; ok {
t.Errorf("%s is declared by the form %q and still listed as pending (%s); remove the pending entry", key, name, lane)
}
}
for _, tr := range forms.ActionTriggers() {
if lane, ok := pendingFormRoutes[tr.Key()]; ok {
t.Errorf("%s is a declared trigger and still listed as pending (%s); remove the pending entry", tr.Key(), lane)
}
}
}