Files
zip/specificity_test.go
zeekayandHanzo Dev 5f71d2496b The router is three nouns, and every one of them is concrete
App, Group, Operation. A group is a prefix and what holds under it, an operation
is one typed route declared there, and the verb methods are generic methods —
which Go 1.27 allows on a concrete type and still refuses on an interface. So In
and Out come from the handler and the caller writes no type arguments:

	users := app.Group("/v1/users")
	users.Post("/", api.Create)

	mint := users.Group("/mint").With(middleware.Mint(gate))
	mint.Post("/deposit", api.Deposit)

A Group is a CLOSED HANDLE: two unexported fields, and no method that hands back
the app, the parent or the fiber underneath. A subsystem given the group at its
own prefix cannot reach above it, so confinement is a property of the type
instead of a rule something has to police — which is what a host needs in order
to hand out a bounded surface and know what comes back.

That is bought by giving up the router interface, deliberately. Router and
OpTarget are gone, and with them the ability to implement a route table outside
this package: a decorator is no longer a router, a gate is middleware on a group,
and a bound is a path. The five package-level verb functions go the same way —
a registration is a method on the thing it registers into.

Raw carries what is not a typed operation: a stream, an upgrade, a probe, a
foreign subtree. It names its method, and it is the longer spelling because
opting out of the typed model is the exception and should read like one.



Co-authored-by: Hanzo Dev <[email protected]>
2026-09-17 01:39:17 -07:00

149 lines
5.3 KiB
Go

package zip_test
import (
"net/http"
"strings"
"testing"
zip "github.com/zap-proto/zip"
)
// These tests pin the route-precedence CONTRACT zip inherits from the
// zap-proto/fiber fork (ServeMux-1.22 semantics): the most specific pattern
// wins regardless of registration order, ambiguous overlaps panic at
// registration, and middleware keeps declaration order. They test the zip
// surface, not the engine — if the engine ever changes, these must still pass.
func specApp() *zip.App {
return zip.New(zip.Config{AppName: "spec-test", DisableStartupMessage: true})
}
func mark(s string) zip.Handler {
return func(c *zip.Ctx) error { return c.JSON(200, map[string]string{"via": s}) }
}
func hit(t *testing.T, app *zip.App, path string) string {
t.Helper()
req, _ := http.NewRequest("GET", path, nil)
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("Test(%s): %v", path, err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("GET %s: status %d, want 200", path, resp.StatusCode)
}
buf := make([]byte, 256)
n, _ := resp.Body.Read(buf)
return string(buf[:n])
}
// The cloud order-int case, inverted: the wildcard is registered FIRST and the
// static route must still win.
func TestSpecificity_StaticBeatsEarlierWildcard(t *testing.T) {
app := specApp()
app.Raw("GET", "/v1/iam/*", mark("wildcard"))
app.Raw("GET", "/v1/iam/keys", mark("static"))
if got := hit(t, app, "/v1/iam/keys"); !strings.Contains(got, "static") {
t.Fatalf("/v1/iam/keys hit %s, want static", got)
}
if got := hit(t, app, "/v1/iam/anything-else"); !strings.Contains(got, "wildcard") {
t.Fatalf("/v1/iam/anything-else hit %s, want wildcard", got)
}
}
// Full ladder registered most-generic-first: static beats :param beats *.
func TestSpecificity_Ladder(t *testing.T) {
app := specApp()
app.Raw("GET", "/users/*", mark("wildcard"))
app.Raw("GET", "/users/:id", mark("param"))
app.Raw("GET", "/users/me", mark("static"))
if got := hit(t, app, "/users/me"); !strings.Contains(got, "static") {
t.Fatalf("/users/me hit %s, want static", got)
}
if got := hit(t, app, "/users/42"); !strings.Contains(got, "param") {
t.Fatalf("/users/42 hit %s, want param", got)
}
if got := hit(t, app, "/users/a/b"); !strings.Contains(got, "wildcard") {
t.Fatalf("/users/a/b hit %s, want wildcard", got)
}
}
// A deeper static pattern beats a shallower wildcard registered before it.
func TestSpecificity_DeeperStaticBeatsShallowWildcard(t *testing.T) {
app := specApp()
app.Raw("GET", "/a/*", mark("wildcard"))
app.Raw("GET", "/a/b/c", mark("deep-static"))
if got := hit(t, app, "/a/b/c"); !strings.Contains(got, "deep-static") {
t.Fatalf("/a/b/c hit %s, want deep-static", got)
}
if got := hit(t, app, "/a/x"); !strings.Contains(got, "wildcard") {
t.Fatalf("/a/x hit %s, want wildcard", got)
}
}
// Two distinct, equally specific, unconstrained patterns are an ambiguous
// overlap: it must be refused loudly, never silently shadow.
//
// CHANGED BY THE LAZY MODEL, deliberately. This used to panic at the second
// app.Get, because registration WAS insertion into the router. A route is now
// an entry in a program and the router is a BUILD of that program, so the
// refusal moves to the build — app.Fiber() here, App.Listen in a real one.
//
// The deferral is not an oversight and cannot be undone. An App may be included
// in another App AFTER its routes are written, so at the moment of any single
// registration the set of patterns it will have to coexist with is not yet
// known; a check at registration time could only ever see part of the program.
// Detecting it at build time is what makes the answer complete — and is the
// same reason conflicting ADDRESSES are now reported all at once with both
// claimants named, instead of one per build.
func TestSpecificity_AmbiguousConflictRefusedAtBuild(t *testing.T) {
app := specApp()
app.Raw("GET", "/x/:id", mark("id"))
app.Raw("GET", "/x/:name", mark("name")) // no longer panics HERE
defer func() {
r := recover()
if r == nil {
t.Fatal("building a router with /x/:name over /x/:id did not panic")
}
msg, _ := r.(string)
if !strings.Contains(msg, "route conflict") {
t.Fatalf("panic %q, want it to name the route conflict", r)
}
}()
app.Fiber()
}
// Same pattern, different methods: no conflict — method stacks are independent.
func TestSpecificity_MethodsIndependent(t *testing.T) {
app := specApp()
app.Raw("GET", "/y/:id", mark("get"))
app.Raw("POST", "/y/:id", mark("post")) // must not panic
if got := hit(t, app, "/y/1"); !strings.Contains(got, "get") {
t.Fatalf("/y/1 hit %s, want get", got)
}
}
// Middleware keeps declaration order and runs before the matched endpoint,
// even though the endpoint is more specific than anything: sorting must never
// hoist an endpoint above middleware (the use-barrier property).
func TestSpecificity_MiddlewareOrderPreserved(t *testing.T) {
app := specApp()
var order []string
app.Use(zip.H(func(c *zip.Ctx) error { order = append(order, "mw1"); return c.Next() }))
app.Use(zip.H(func(c *zip.Ctx) error { order = append(order, "mw2"); return c.Next() }))
app.Raw("GET", "/z", func(c *zip.Ctx) error {
order = append(order, "handler")
return c.JSON(200, map[string]string{"via": "z"})
})
_ = hit(t, app, "/z")
if strings.Join(order, ",") != "mw1,mw2,handler" {
t.Fatalf("execution order %v, want [mw1 mw2 handler]", order)
}
}