Files
zip/oneclaim_test.go
zeekayandHanzo Dev cf68b751f3 Shadow is removed: a braided program is unbraided, not accommodated
v1.24.2 shipped two fixes. The operation-id one stays and is untouched here: a
declared WithOperationID survives composition verbatim (measured on hanzoai/o11y
— 353 ops, 217 declare an id, all 217 were being renamed by a host prefix and
now are not). The address one, App.Shadow(), is removed.

Shadow existed to make an op declarable in one place and answerable in another:
a service installing its published table of ops onto the router its
implementation already serves on, a test registering a second handler at an
address deliberately. Both are the same shape — a declaration and the handler
that satisfies it, written apart, with a framework verb holding the two ends
together. That is a braid, and a framework that grows a verb per braid grows one
verb per caller. The fix belongs in the caller: a typed registration is ALREADY
both halves, the handler the router installs and the op the document, MCP tool
list, CLI and SDK all read, so the declaration and the answer can simply be the
same line. Use composes definitions, Group scopes them, and between them every
shape Shadow was written for is expressible with one claim per address.

So the address check goes back to what it was, exactly: one registration at an
address, both parties named when there are two, no scope and no option that can
talk it out of an accident. addrClaim, addressErrors and addressRules are gone
with it — 190 lines of walk.go that existed only to reason about the SHAPE of
multiple claims at one address, a question the language no longer asks.

Removed: App.Shadow, OpScope.Shadow, route.shadow, App.shadow, wrapRouter.shadow
and yields, App.method's shadow parameter. wrapChain and OpScope lose their
nil-wrap branches, which existed only for a Shadow-only scope with no middleware
to compose. No caller outside this repo referenced any of it at a released tag.

Tests: shadow_test.go deleted (250 lines, ten cases, all about the removed
verb). oneclaim_test.go replaces it with three that pin the rule from both
sides — the braid is refused with both parties named, one registration declares
AND answers, and Use+Group place a definition's ops wherever a host wants them
with one claim per address. Everything else is unchanged, including all of
declaredid_test.go and the unnamedApp/billingApp split in compose_test.go, which
are the id fix and not this one.

319 root-package tests pass on a clean testcache and under -race.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 09:21:25 -07:00

102 lines
3.8 KiB
Go

package zip
import (
"context"
"strings"
"testing"
)
// ONE ADDRESS, ONE REGISTRATION — and that registration both declares and answers.
//
// v1.24.2 shipped App.Shadow(), a scope whose registrations yielded their
// address so that an op could be DECLARED in one place and ANSWERED in another.
// It was removed in v1.24.3. The shape it accommodated is a braid: a
// declaration and the handler that satisfies it, written apart, held together
// by a framework verb. Unbraiding costs nothing, because a typed registration
// is ALREADY both halves — the handler the router installs and the op every
// projection reads — so the two can simply be the same line.
//
// These tests pin both directions: the braid is refused, the unbraided program
// is complete.
// braidedImpl answers the address.
func braidedImpl() *App {
a := quiet("impl")
a.Post("/v1/o11y/roles", func(c *Ctx) error { return c.String(200, "runtime") })
return a
}
// braidedTable declares the same address somewhere else. Two claims.
func braidedTable() *App {
t := quiet("table")
Post(t.Group("/v1/o11y"), "/roles", func(_ context.Context, in *roleIn) (*roleOut, error) {
return &roleOut{ID: in.Name}, nil
}, WithOperationID("CreateRole"))
return t
}
// TestDeclaringApartFromTheHandlerIsRefused: there is no scope, no option and
// no verb that makes two registrations at one address legal. The check has one
// behaviour and cannot be talked out of it.
func TestDeclaringApartFromTheHandlerIsRefused(t *testing.T) {
host := quiet("host")
host.Use(braidedImpl())
host.Use(braidedTable())
err := host.Build()
if err == nil {
t.Fatal("a declaration registered apart from its handler was accepted — " +
"two claims at one address must stay a refused program")
}
// Both parties named, in the words the check has always used.
for _, want := range []string{"POST /v1/o11y/roles", "declared by", "impl", "table"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the refusal does not mention %q: %v", want, err)
}
}
}
// TestOneRegistrationDeclaresAndAnswers is the same intent, unbraided: the op
// reaches the document under the id its author wrote, AND it is what answers
// the address. Nothing to keep in sync, because there is only one thing.
func TestOneRegistrationDeclaresAndAnswers(t *testing.T) {
svc := quiet("o11y")
Post(svc.Group("/v1/o11y"), "/roles", func(_ context.Context, in *roleIn) (*roleOut, error) {
return &roleOut{ID: in.Name}, nil
}, WithOperationID("CreateRole"))
host := quiet("host")
host.Use(svc)
if err := host.Build(); err != nil {
t.Fatalf("build: %v", err)
}
reg := host.Registry()
if len(reg) != 1 {
t.Fatalf("%d ops, want 1", len(reg))
}
if reg[0].OperationID != "CreateRole" || reg[0].Path != "/v1/o11y/roles" {
t.Fatalf("op = %s at %s, want CreateRole at /v1/o11y/roles", reg[0].OperationID, reg[0].Path)
}
if status, body := postJSON(t, host, "/v1/o11y/roles", `{"name":"admin"}`); status != 200 || !strings.Contains(body, `"admin"`) {
t.Fatalf("POST /v1/o11y/roles: status=%d body=%q — the declaration must also be what answers", status, body)
}
}
// TestGroupIsTheOnlyScopeARegistrationNeeds: Use and Group between them place a
// definition's ops wherever a host wants them, and the address check still sees
// exactly one claim per address. This is the whole replacement for Shadow.
func TestGroupIsTheOnlyScopeARegistrationNeeds(t *testing.T) {
host := quiet("host")
host.Group("/v1").Use(service()) // service() declares CreateRole at /roles
host.Get("/v1/health", func(c *Ctx) error { return c.String(200, "ok") })
if err := host.Build(); err != nil {
t.Fatalf("build: %v", err)
}
reg := host.Registry()
if len(reg) != 1 || reg[0].OperationID != "CreateRole" || reg[0].Path != "/v1/roles" {
t.Fatalf("registry = %+v, want CreateRole at /v1/roles", reg)
}
}