// Package transporttest provides the reusable acceptance suite for GoOmni // router adapters. Import it only from tests and pass a fresh router factory. // Register routes before serving; the suite exercises concurrent requests. package transporttest import ( "context" "encoding/json" "net/http" "net/http/httptest" "reflect" "strings" "testing" "git.yuntuo.hk/k2safe/go-omni/httpx" ) // Run verifies routing and the same business handlers on a supplied adapter. // Factories are called serially and must return a fresh, empty router. func Run(t *testing.T, newRouter func() httpx.Router) { t.Helper() t.Run("routing", func(t *testing.T) { routing(t, newRouter) }) t.Run("registration", func(t *testing.T) { registration(t, newRouter) }) t.Run("application", func(t *testing.T) { application(t, newRouter) }) } type contextKey struct{} type routeResult struct { Pattern string `json:"pattern"` Params map[string]string `json:"params"` Value string `json:"value"` } func routing(t *testing.T, newRouter func() httpx.Router) { r := newRouter() echo := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { value, _ := req.Context().Value(contextKey{}).(string) _ = json.NewEncoder(w).Encode(routeResult{ Pattern: httpx.RoutePattern(req.Context()), Params: httpx.RouteParams(req.Context()), Value: value, }) }) for _, path := range []string{ "/", "/users/:id", "/users/me", "/files/*path", "/slash/", "/choice/:first/fixed/final", "/choice/static/:second/:third", "/back/static", "/back/:id/details", } { r.Handle(http.MethodGet, path, echo) } // Registration order must not determine static-vs-parameter precedence. r.Handle(http.MethodPost, "/users/me", echo) r.Handle(http.MethodPost, "/users/:id", echo) r.Handle(http.MethodHead, "/head", echo) r.Handle(http.MethodOptions, "/head", echo) r.Handle("REPORT", "/report", echo) group := r.Group("/api/").Group("v1") group.Handle(http.MethodGet, "/users/:id", echo) group.Handle(http.MethodGet, "", echo) group.Handle(http.MethodGet, "/", echo) group.Any("/any", echo) r.Any("/any", echo) cases := []struct { method, path, pattern, param, value string status int allow string }{ {"GET", "/", "/", "", "", 200, ""}, {"GET", "/users/42?x=y", "/users/:id", "id", "42", 200, ""}, {"GET", "/users/me", "/users/me", "", "", 200, ""}, {"POST", "/users/me", "/users/me", "", "", 200, ""}, {"GET", "/users/%E4%B8%AD%E6%96%87", "/users/:id", "id", "中文", 200, ""}, {"GET", "/users/a%2Fb", "", "", "", 404, ""}, {"GET", "/users/a+b", "/users/:id", "id", "a+b", 200, ""}, {"GET", "/users/", "", "", "", 404, ""}, {"GET", "/users/42/", "", "", "", 404, ""}, {"GET", "/users//42", "", "", "", 404, ""}, {"GET", "/Users/42", "", "", "", 404, ""}, {"GET", "/slash", "", "", "", 404, ""}, {"GET", "/slash/", "/slash/", "", "", 200, ""}, {"GET", "/files", "", "", "", 404, ""}, {"GET", "/files/", "/files/*path", "path", "/", 200, ""}, {"GET", "/files/a%2Fb/c", "/files/*path", "path", "/a/b/c", 200, ""}, {"GET", "/files//a", "/files/*path", "path", "//a", 200, ""}, {"GET", "/choice/static/fixed/final", "/choice/static/:second/:third", "second", "fixed", 200, ""}, {"GET", "/back/static/details", "/back/:id/details", "id", "static", 200, ""}, {"GET", "/api/v1/users/42", "/api/v1/users/:id", "id", "42", 200, ""}, {"GET", "/api/v1", "/api/v1", "", "", 200, ""}, {"GET", "/api/v1/", "/api/v1/", "", "", 200, ""}, {"REPORT", "/report", "/report", "", "", 200, ""}, {"HEAD", "/head", "/head", "", "", 200, ""}, {"OPTIONS", "/head", "/head", "", "", 200, ""}, {"HEAD", "/users/42", "", "", "", 405, "GET, POST"}, {"OPTIONS", "/users/42", "", "", "", 405, "GET, POST"}, {"DELETE", "/users/me", "", "", "", 405, "GET, POST"}, {"GET", "/head", "", "", "", 405, "HEAD, OPTIONS"}, {"GET", "/missing", "", "", "", 404, ""}, {"REPORT", "/any", "", "", "", 405, "CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE"}, } for _, method := range []string{"GET", "POST", "PUT", "PATCH", "HEAD", "OPTIONS", "DELETE", "CONNECT", "TRACE"} { for _, path := range []string{"/any", "/api/v1/any"} { cases = append(cases, struct { method, path, pattern, param, value string status int allow string }{method, path, path, "", "", 200, ""}) } } for _, tc := range cases { t.Run(tc.method+" "+tc.path, func(t *testing.T) { req := httptest.NewRequest(tc.method, tc.path, nil) // CONNECT has authority-form handling in net/http's parser; the // adapter contract consumes an already parsed URL.Path. if tc.method == "CONNECT" { req.URL.Path = tc.path } req = req.WithContext(context.WithValue(req.Context(), contextKey{}, "preserved")) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != tc.status || w.Header().Get("Allow") != tc.allow || w.Header().Get("Location") != "" { t.Fatalf("status=%d allow=%q location=%q body=%s", w.Code, w.Header().Get("Allow"), w.Header().Get("Location"), w.Body) } if tc.status != 200 { want := "404 page not found\n" if tc.status == 405 { want = "Method Not Allowed\n" } if w.Body.String() != want || !strings.HasPrefix(w.Header().Get("Content-Type"), "text/plain") { t.Fatalf("unexpected fallback response: %s", w.Body) } return } var got routeResult if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { t.Fatal(err) } wantParams := map[string]string{} if tc.param != "" { wantParams[tc.param] = tc.value } // Routes with several parameters are checked in full. if tc.pattern == "/choice/static/:second/:third" { wantParams["third"] = "final" } if got.Pattern != tc.pattern || !reflect.DeepEqual(got.Params, wantParams) || got.Value != "preserved" { t.Fatalf("route metadata=%+v, want %s %v with original context", got, tc.pattern, wantParams) } }) } } func registration(t *testing.T, newRouter func() httpx.Router) { handler := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}) cases := []struct { name string register func(httpx.Router) }{ {"nil handler", func(r httpx.Router) { r.Handle("GET", "/", nil) }}, {"empty method", func(r httpx.Router) { r.Handle("", "/", handler) }}, {"lowercase method", func(r httpx.Router) { r.Handle("get", "/", handler) }}, } for _, path := range []string{"", "relative", "/:", "/*", "/a/*rest/b", "/a/:id/:id", "/a//b", "/a/../b", "/a?b", "/prefix:id", "/:a:b"} { cases = append(cases, struct { name string register func(httpx.Router) }{"invalid " + path, func(r httpx.Router) { r.Handle("GET", path, handler) }}) } for _, pair := range [][2]string{ {"/users", "/users"}, {"/users/:id", "/users/:name/more"}, {"/files/*path", "/files/static"}, {"/files/static", "/files/*path"}, {"/", "/*path"}, {"/*path", "/"}, } { cases = append(cases, struct { name string register func(httpx.Router) }{"conflict " + pair[0] + " " + pair[1], func(r httpx.Router) { r.Handle("GET", pair[0], handler) r.Handle("GET", pair[1], handler) }}) } cases = append(cases, struct { name string register func(httpx.Router) }{"Any conflicts with explicit method", func(r httpx.Router) { r.Any("/any", handler) r.Handle("GET", "/any", handler) }}) for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { defer func() { if recover() == nil { t.Error("registration must panic") } }() tc.register(newRouter()) }) } }