Files
base/tools/router/unmarshal_request_data_test.go
zeekayandHanzo Dev b717e70c6f ci: the canonical lane — root hanzo.yml + .hanzo/workflows/cicd.yml
This repo built images and gated NOTHING. 64 Go packages with tests, and CI ran
none of them, which is why `go vet ./...` had been failing on main unseen.

Two files. `hanzo.yml` says what this repo is (three gates, one image, no
deploy); `.hanzo/workflows/cicd.yml` is ~10 lines importing
hanzoai/ci/.hanzo/workflows/build.yml@v1. `.hanzo`, not `.github`: github.com has
zero self-hosted runners for this org, so a job asking for
hanzo-build-linux-amd64 there is never claimed — it waits out the 24h timeout
rather than failing. Gitea also reads only the FIRST of WORKFLOW_DIRS present in
the commit, so `.github/workflows` has been dark here all along.

deploy.yml is deliberately LEFT IN PLACE by this commit. It is the current green
lane (36 runs, main and v* tags alike) and it does not come out until the new one
has been watched green and shown to publish the same image.

The gates, and why each is the command it is:

  go build ./...   fails first and legibly on a compile error
  go vet ./...     was RED on main. One line: an unexported field in
                   tools/router/unmarshal_request_data_test.go carried a `json`
                   tag that the code under test never reads —
                   UnmarshalRequestData defaults structTagKey to "form", and the
                   `form` tag beside it is untouched. So the assertion is
                   identical and vet is honest again. Fixed here rather than
                   suppressed, because a suppressed analyzer is a gate that
                   cannot say what it is for.
  go test ./...    the whole tree; 64 packages ok.

All three export CGO_ENABLED=0, which is not a preference — it is how Base
ships, and testing it any other way tests a program we do not publish. With cgo
ON the runner links a system SQLite with no math functions, `tests.NewTestApp()`
returns an error instead of an app, and every suite that needs one panics on the
nil: measured, 24 of 64 packages FAIL, all one cause. The engine says so itself
("Rebuild with CGO_ENABLED=0 (how Base ships)"). GOWORK=off for the same class of
reason — a parent go.work must not decide which modules CI resolves.

No `deploy:`, transcribing what deploy.yml already said. Base does not run
anywhere as itself: ghcr.io/hanzoai/base is a base image four products embed at
four different pinned tags (platform 1.3.0, hanzo-app-base 0.39.16, cal
cal-a2b6ce3, superbase 1.3.2), each a reviewed change in hanzoai/universe. Even
base.hanzo.ai is served by superbase, which embeds base/ui-react at its own
go.mod pin. Four consumers on four tags is exactly why choosing which one runs is
not a CI side effect, and cd.hanzo.ai's selfHeal would revert a direct patch
within minutes regardless.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 17:08:13 -07:00

493 lines
15 KiB
Go

package router_test
import (
"bytes"
"encoding/json"
"testing"
"time"
"github.com/hanzoai/base/tools/router"
)
func pointer[T any](val T) *T {
return &val
}
func TestUnmarshalRequestData(t *testing.T) {
t.Parallel()
mapData := map[string][]string{
"number1": {"1"},
"number2": {"2", "3"},
"number3": {"2.1", "-3.4"},
"number4": {"0", "-0", "0.0001"},
"string0": {""},
"string1": {"a"},
"string2": {"b", "c"},
"string3": {
"0.0",
"-0.0",
"000.1",
"000001",
"-000001",
"1.6E-35",
"-1.6E-35",
"10e100",
"1_000_000",
"1.000.000",
" 123 ",
"0b1",
"0xFF",
"1234A",
"Infinity",
"-Infinity",
"undefined",
"null",
},
"bool1": {"true"},
"bool2": {"true", "false"},
"mixed": {"true", "123", "test"},
"@jsonPayload": {`{"json_a":null,"json_b":123}`, `{"json_c":[1,2,3]}`},
}
structData := map[string][]string{
"anyTag": {"a", "b"},
"AnyPtr": {"b"},
"AnySlice": {"a", "b", "false", ""},
"anySlicePtrTag": {"d", "true"},
"AnySliceOfPtr": {"f", "123.456"},
"stringTag": {"a", "b"},
"StringPtr": {"b"},
"StringSlice": {"a", "b", "c", ""},
"stringSlicePtrTag": {"d", "e"},
"StringSliceOfPtr": {"f", "g"},
"boolTag": {"true"},
"BoolPtr": {"true"},
"BoolSlice": {"true", "false", ""},
"boolSlicePtrTag": {"false", "false", "true"},
"BoolSliceOfPtr": {"false", "true", "false"},
"int8Tag": {"-1", "2"},
"Int8Ptr": {"3"},
"Int8Slice": {"4", "5", ""},
"int8SlicePtrTag": {"5", "6"},
"Int8SliceOfPtr": {"7", "8"},
"int16Tag": {"-1", "2"},
"Int16Ptr": {"3"},
"Int16Slice": {"4", "5", ""},
"int16SlicePtrTag": {"5", "6"},
"Int16SliceOfPtr": {"7", "8"},
"int32Tag": {"-1", "2"},
"Int32Ptr": {"3"},
"Int32Slice": {"4", "5", ""},
"int32SlicePtrTag": {"5", "6"},
"Int32SliceOfPtr": {"7", "8"},
"int64Tag": {"-1", "2"},
"Int64Ptr": {"3"},
"Int64Slice": {"4", "5", ""},
"int64SlicePtrTag": {"5", "6"},
"Int64SliceOfPtr": {"7", "8"},
"intTag": {"-1", "2"},
"IntPtr": {"3"},
"IntSlice": {"4", "5", ""},
"intSlicePtrTag": {"5", "6"},
"IntSliceOfPtr": {"7", "8"},
"uint8Tag": {"1", "2"},
"Uint8Ptr": {"3"},
"Uint8Slice": {"4", "5", ""},
"uint8SlicePtrTag": {"5", "6"},
"Uint8SliceOfPtr": {"7", "8"},
"uint16Tag": {"1", "2"},
"Uint16Ptr": {"3"},
"Uint16Slice": {"4", "5", ""},
"uint16SlicePtrTag": {"5", "6"},
"Uint16SliceOfPtr": {"7", "8"},
"uint32Tag": {"1", "2"},
"Uint32Ptr": {"3"},
"Uint32Slice": {"4", "5", ""},
"uint32SlicePtrTag": {"5", "6"},
"Uint32SliceOfPtr": {"7", "8"},
"uint64Tag": {"1", "2"},
"Uint64Ptr": {"3"},
"Uint64Slice": {"4", "5", ""},
"uint64SlicePtrTag": {"5", "6"},
"Uint64SliceOfPtr": {"7", "8"},
"uintTag": {"1", "2"},
"UintPtr": {"3"},
"UintSlice": {"4", "5", ""},
"uintSlicePtrTag": {"5", "6"},
"UintSliceOfPtr": {"7", "8"},
"float32Tag": {"-1.2"},
"Float32Ptr": {"1.5", "2.0"},
"Float32Slice": {"1", "2.3", "-0.3", ""},
"float32SlicePtrTag": {"-1.3", "3"},
"Float32SliceOfPtr": {"0", "1.2"},
"float64Tag": {"-1.2"},
"Float64Ptr": {"1.5", "2.0"},
"Float64Slice": {"1", "2.3", "-0.3", ""},
"float64SlicePtrTag": {"-1.3", "3"},
"Float64SliceOfPtr": {"0", "1.2"},
"timeTag": {"2009-11-10T15:00:00Z"},
"TimePtr": {"2009-11-10T14:00:00Z", "2009-11-10T15:00:00Z"},
"TimeSlice": {"2009-11-10T14:00:00Z", "2009-11-10T15:00:00Z"},
"timeSlicePtrTag": {"2009-11-10T15:00:00Z", "2009-11-10T16:00:00Z"},
"TimeSliceOfPtr": {"2009-11-10T17:00:00Z", "2009-11-10T18:00:00Z"},
// @jsonPayload fields
"@jsonPayload": {
`{"payloadA":"test", "shouldBeIgnored": "abc"}`,
`{"payloadB":[1,2,3], "payloadC":true}`,
},
// unexported fields or `-` tags
"unexperted": {"test"},
"SkipExported": {"test"},
"unexportedStructFieldWithoutTag.Name": {"test"},
"unexportedStruct.Name": {"test"},
// structs
"StructWithoutTag.Name": {"test1"},
"exportedStruct.Name": {"test2"},
// embedded
"embed_name": {"test3"},
"embed2.embed_name2": {"test4"},
}
type embed1 struct {
Name string `form:"embed_name" json:"embed_name"`
}
type embed2 struct {
Name string `form:"embed_name2" json:"embed_name2"`
}
//nolint
type TestStruct struct {
Any any `form:"anyTag" query:"anyTag2"`
AnyPtr *any
AnySlice []any
AnySlicePtr *[]any `form:"anySlicePtrTag"`
AnySliceOfPtr []*any
String string `form:"stringTag" query:"stringTag2"`
StringPtr *string
StringSlice []string
StringSlicePtr *[]string `form:"stringSlicePtrTag"`
StringSliceOfPtr []*string
Bool bool `form:"boolTag" query:"boolTag2"`
BoolPtr *bool
BoolSlice []bool
BoolSlicePtr *[]bool `form:"boolSlicePtrTag"`
BoolSliceOfPtr []*bool
Int8 int8 `form:"int8Tag" query:"int8Tag2"`
Int8Ptr *int8
Int8Slice []int8
Int8SlicePtr *[]int8 `form:"int8SlicePtrTag"`
Int8SliceOfPtr []*int8
Int16 int16 `form:"int16Tag" query:"int16Tag2"`
Int16Ptr *int16
Int16Slice []int16
Int16SlicePtr *[]int16 `form:"int16SlicePtrTag"`
Int16SliceOfPtr []*int16
Int32 int32 `form:"int32Tag" query:"int32Tag2"`
Int32Ptr *int32
Int32Slice []int32
Int32SlicePtr *[]int32 `form:"int32SlicePtrTag"`
Int32SliceOfPtr []*int32
Int64 int64 `form:"int64Tag" query:"int64Tag2"`
Int64Ptr *int64
Int64Slice []int64
Int64SlicePtr *[]int64 `form:"int64SlicePtrTag"`
Int64SliceOfPtr []*int64
Int int `form:"intTag" query:"intTag2"`
IntPtr *int
IntSlice []int
IntSlicePtr *[]int `form:"intSlicePtrTag"`
IntSliceOfPtr []*int
Uint8 uint8 `form:"uint8Tag" query:"uint8Tag2"`
Uint8Ptr *uint8
Uint8Slice []uint8
Uint8SlicePtr *[]uint8 `form:"uint8SlicePtrTag"`
Uint8SliceOfPtr []*uint8
Uint16 uint16 `form:"uint16Tag" query:"uint16Tag2"`
Uint16Ptr *uint16
Uint16Slice []uint16
Uint16SlicePtr *[]uint16 `form:"uint16SlicePtrTag"`
Uint16SliceOfPtr []*uint16
Uint32 uint32 `form:"uint32Tag" query:"uint32Tag2"`
Uint32Ptr *uint32
Uint32Slice []uint32
Uint32SlicePtr *[]uint32 `form:"uint32SlicePtrTag"`
Uint32SliceOfPtr []*uint32
Uint64 uint64 `form:"uint64Tag" query:"uint64Tag2"`
Uint64Ptr *uint64
Uint64Slice []uint64
Uint64SlicePtr *[]uint64 `form:"uint64SlicePtrTag"`
Uint64SliceOfPtr []*uint64
Uint uint `form:"uintTag" query:"uintTag2"`
UintPtr *uint
UintSlice []uint
UintSlicePtr *[]uint `form:"uintSlicePtrTag"`
UintSliceOfPtr []*uint
Float32 float32 `form:"float32Tag" query:"float32Tag2"`
Float32Ptr *float32
Float32Slice []float32
Float32SlicePtr *[]float32 `form:"float32SlicePtrTag"`
Float32SliceOfPtr []*float32
Float64 float64 `form:"float64Tag" query:"float64Tag2"`
Float64Ptr *float64
Float64Slice []float64
Float64SlicePtr *[]float64 `form:"float64SlicePtrTag"`
Float64SliceOfPtr []*float64
// encoding.TextUnmarshaler
Time time.Time `form:"timeTag" query:"timeTag2"`
TimePtr *time.Time
TimeSlice []time.Time
TimeSlicePtr *[]time.Time `form:"timeSlicePtrTag"`
TimeSliceOfPtr []*time.Time
// @jsonPayload fields
JSONPayloadA string `form:"shouldBeIgnored" json:"payloadA"`
JSONPayloadB []int `json:"payloadB"`
JSONPayloadC bool `json:"-"`
// unexported fields or `-` tags
unexported string
SkipExported string `form:"-"`
unexportedStructFieldWithoutTag struct {
Name string `json:"unexportedStructFieldWithoutTag_name"`
}
unexportedStructFieldWithTag struct {
Name string `json:"unexportedStructFieldWithTag_name"`
} `form:"unexportedStruct"`
// structs
StructWithoutTag struct {
Name string `json:"StructWithoutTag_name"`
}
StructWithTag struct {
Name string `json:"StructWithTag_name"`
} `form:"exportedStruct"`
// embedded
embed1
embed2 `form:"embed2"`
}
scenarios := []struct {
name string
data map[string][]string
dst any
tag string
prefix string
error bool
result string
}{
{
name: "nil data",
data: nil,
dst: pointer(map[string]any{}),
error: false,
result: `{}`,
},
{
name: "non-pointer map[string]any",
data: mapData,
dst: map[string]any{},
error: true,
},
{
name: "unsupported *map[string]string",
data: mapData,
dst: pointer(map[string]string{}),
error: true,
},
{
name: "unsupported *map[string][]string",
data: mapData,
dst: pointer(map[string][]string{}),
error: true,
},
{
name: "*map[string]any",
data: mapData,
dst: pointer(map[string]any{}),
result: `{"bool1":true,"bool2":[true,false],"json_a":null,"json_b":123,"json_c":[1,2,3],"mixed":[true,123,"test"],"number1":1,"number2":[2,3],"number3":[2.1,-3.4],"number4":[0,-0,0.0001],"string0":"","string1":"a","string2":["b","c"],"string3":["0.0","-0.0","000.1","000001","-000001","1.6E-35","-1.6E-35","10e100","1_000_000","1.000.000"," 123 ","0b1","0xFF","1234A","Infinity","-Infinity","undefined","null"]}`,
},
{
name: "valid pointer struct (all fields)",
data: structData,
dst: &TestStruct{},
result: `{"Any":"a","AnyPtr":"b","AnySlice":["a","b",false,""],"AnySlicePtr":["d",true],"AnySliceOfPtr":["f",123.456],"String":"a","StringPtr":"b","StringSlice":["a","b","c",""],"StringSlicePtr":["d","e"],"StringSliceOfPtr":["f","g"],"Bool":true,"BoolPtr":true,"BoolSlice":[true,false,false],"BoolSlicePtr":[false,false,true],"BoolSliceOfPtr":[false,true,false],"Int8":-1,"Int8Ptr":3,"Int8Slice":[4,5,0],"Int8SlicePtr":[5,6],"Int8SliceOfPtr":[7,8],"Int16":-1,"Int16Ptr":3,"Int16Slice":[4,5,0],"Int16SlicePtr":[5,6],"Int16SliceOfPtr":[7,8],"Int32":-1,"Int32Ptr":3,"Int32Slice":[4,5,0],"Int32SlicePtr":[5,6],"Int32SliceOfPtr":[7,8],"Int64":-1,"Int64Ptr":3,"Int64Slice":[4,5,0],"Int64SlicePtr":[5,6],"Int64SliceOfPtr":[7,8],"Int":-1,"IntPtr":3,"IntSlice":[4,5,0],"IntSlicePtr":[5,6],"IntSliceOfPtr":[7,8],"Uint8":1,"Uint8Ptr":3,"Uint8Slice":"BAUA","Uint8SlicePtr":"BQY=","Uint8SliceOfPtr":[7,8],"Uint16":1,"Uint16Ptr":3,"Uint16Slice":[4,5,0],"Uint16SlicePtr":[5,6],"Uint16SliceOfPtr":[7,8],"Uint32":1,"Uint32Ptr":3,"Uint32Slice":[4,5,0],"Uint32SlicePtr":[5,6],"Uint32SliceOfPtr":[7,8],"Uint64":1,"Uint64Ptr":3,"Uint64Slice":[4,5,0],"Uint64SlicePtr":[5,6],"Uint64SliceOfPtr":[7,8],"Uint":1,"UintPtr":3,"UintSlice":[4,5,0],"UintSlicePtr":[5,6],"UintSliceOfPtr":[7,8],"Float32":-1.2,"Float32Ptr":1.5,"Float32Slice":[1,2.3,-0.3,0],"Float32SlicePtr":[-1.3,3],"Float32SliceOfPtr":[0,1.2],"Float64":-1.2,"Float64Ptr":1.5,"Float64Slice":[1,2.3,-0.3,0],"Float64SlicePtr":[-1.3,3],"Float64SliceOfPtr":[0,1.2],"Time":"2009-11-10T15:00:00Z","TimePtr":"2009-11-10T14:00:00Z","TimeSlice":["2009-11-10T14:00:00Z","2009-11-10T15:00:00Z"],"TimeSlicePtr":["2009-11-10T15:00:00Z","2009-11-10T16:00:00Z"],"TimeSliceOfPtr":["2009-11-10T17:00:00Z","2009-11-10T18:00:00Z"],"payloadA":"test","payloadB":[1,2,3],"SkipExported":"","StructWithoutTag":{"StructWithoutTag_name":"test1"},"StructWithTag":{"StructWithTag_name":"test2"},"embed_name":"test3","embed_name2":"test4"}`,
},
{
name: "non-pointer struct",
data: structData,
dst: TestStruct{},
error: true,
},
{
name: "invalid struct uint value",
data: map[string][]string{"uintTag": {"-1"}},
dst: &TestStruct{},
error: true,
},
{
name: "invalid struct int value",
data: map[string][]string{"intTag": {"abc"}},
dst: &TestStruct{},
error: true,
},
{
name: "invalid struct bool value",
data: map[string][]string{"boolTag": {"abc"}},
dst: &TestStruct{},
error: true,
},
{
name: "invalid struct float value",
data: map[string][]string{"float64Tag": {"abc"}},
dst: &TestStruct{},
error: true,
},
{
name: "invalid struct TextUnmarshaler value",
data: map[string][]string{"timeTag": {"123"}},
dst: &TestStruct{},
error: true,
},
{
name: "custom tagKey",
data: map[string][]string{
"tag1": {"a"},
"tag2": {"b"},
"tag3": {"c"},
"Item": {"d"},
},
dst: &struct {
Item string `form:"tag1" query:"tag2" json:"tag2"`
}{},
tag: "query",
result: `{"tag2":"b"}`,
},
{
name: "custom prefix",
data: map[string][]string{
"test.A": {"1"},
"A": {"2"},
"test.alias": {"3"},
},
dst: &struct {
A string
B string `form:"alias"`
}{},
prefix: "test",
result: `{"A":"1","B":"3"}`,
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
err := router.UnmarshalRequestData(s.data, s.dst, s.tag, s.prefix)
hasErr := err != nil
if hasErr != s.error {
t.Fatalf("Expected hasErr %v, got %v (%v)", s.error, hasErr, err)
}
if hasErr {
return
}
raw, err := json.Marshal(s.dst)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(raw, []byte(s.result)) {
t.Fatalf("Expected dst \n%s\ngot\n%s", s.result, raw)
}
})
}
}
// note: extra unexported checks in addition to the above test as there
// is no easy way to print nested structs with all their fields.
func TestUnmarshalRequestDataUnexportedFields(t *testing.T) {
t.Parallel()
//nolint:all
type TestStruct struct {
Exported string
unexported string
// to ensure that the reflection doesn't take tags with higher priority than the exported state
//
// The tag is `form` ONLY. UnmarshalRequestData is called below with an
// empty structTagKey, which defaults to "form" — that is the one tag the
// code under test ever reads, so it is the one that makes this case mean
// what it says. The `json` tag that used to sit beside it was read by
// nothing here and was the sole `go vet ./...` failure in the whole
// module (structtag: "has json tag but is not exported" — vet's rule is
// about encoding/json, which is not in this path at all). Deleting it
// keeps the assertion identical and lets vet gate the repo.
unexportedWithTag string `form:"unexportedWithTag"`
}
dst := &TestStruct{}
err := router.UnmarshalRequestData(map[string][]string{
"Exported": {"test"}, // just for reference
"Unexported": {"test"},
"unexported": {"test"},
"UnexportedWithTag": {"test"},
"unexportedWithTag": {"test"},
}, dst, "", "")
if err != nil {
t.Fatal(err)
}
if dst.Exported != "test" {
t.Fatalf("Expected the Exported field to be %q, got %q", "test", dst.Exported)
}
if dst.unexported != "" {
t.Fatalf("Expected the unexported field to remain empty, got %q", dst.unexported)
}
if dst.unexportedWithTag != "" {
t.Fatalf("Expected the unexportedWithTag field to remain empty, got %q", dst.unexportedWithTag)
}
}