[
  {
    "path": ".github/CODEOWNERS",
    "content": "# These owners will be the default owners for everything in the repo.\n# Unless a later match takes precedence, @umputun will be requested for\n# review when someone opens a pull request.\n\n*       @umputun\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "github: [umputun]\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: build\n\non:\n  push:\n    branches:\n    tags:\n  pull_request:\n\npermissions:\n  contents: read  # to fetch code (actions/checkout)\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: checkout\n        uses: actions/checkout@v4\n\n      - name: set up go\n        uses: actions/setup-go@v5\n        with:\n          go-version: \"1.23\"\n        id: go\n\n      - name: build and test\n        run: |\n          go get -v\n          go test -timeout=60s -race -covermode=atomic -coverprofile=$GITHUB_WORKSPACE/profile.cov_tmp\n          cat $GITHUB_WORKSPACE/profile.cov_tmp | grep -v \"_mock.go\" > $GITHUB_WORKSPACE/profile.cov\n          go build -race\n        env:\n          GO111MODULE: \"on\"\n          TZ: \"America/Chicago\"\n\n      - name: golangci-lint\n        uses: golangci/golangci-lint-action@v7\n        with:\n          version: v2.6\n\n      - name: install goveralls\n        run: |\n          go install github.com/mattn/goveralls@latest\n\n      - name: submit coverage\n        run: $(go env GOPATH)/bin/goveralls -service=\"github\" -coverprofile=$GITHUB_WORKSPACE/profile.cov\n        env:\n          COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }}"
  },
  {
    "path": ".gitignore",
    "content": "# Coverage files\ncoverage.out\ncoverage.html\n*.cover\n\n# Test binaries\n*.test\n\n# Go workspace files\ngo.work\ngo.work.sum"
  },
  {
    "path": ".golangci.yml",
    "content": "version: \"2\"\nrun:\n  concurrency: 4\nlinters:\n  default: none\n  enable:\n    - copyloopvar\n    - gochecknoinits\n    - gocritic\n    - gosec\n    - govet\n    - ineffassign\n    - misspell\n    - nakedret\n    - prealloc\n    - revive\n    - staticcheck\n    - unconvert\n    - unparam\n    - unused\n  settings:\n    goconst:\n      min-len: 2\n      min-occurrences: 2\n    gocritic:\n      disabled-checks:\n        - wrapperFunc\n      enabled-tags:\n        - performance\n        - style\n        - experimental\n    gocyclo:\n      min-complexity: 15\n    govet:\n      enable:\n        - shadow\n    lll:\n      line-length: 140\n    misspell:\n      locale: US\n  exclusions:\n    generated: lax\n    rules:\n      - linters:\n          - gosec\n        text: 'G114: Use of net/http serve function that has no support for setting timeouts'\n      - linters:\n          - revive\n          - unparam\n        path: _test\\.go$\n        text: unused-parameter\n    paths:\n      - third_party$\n      - builtin$\n      - examples$\nformatters:\n  exclusions:\n    generated: lax\n    paths:\n      - third_party$\n      - builtin$\n      - examples$\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2024 Umputun\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "## routegroup [![Build Status](https://github.com/go-pkgz/routegroup/workflows/build/badge.svg)](https://github.com/go-pkgz/routegroup/actions) [![Go Report Card](https://goreportcard.com/badge/github.com/go-pkgz/routegroup)](https://goreportcard.com/report/github.com/go-pkgz/routegroup) [![Coverage Status](https://coveralls.io/repos/github/go-pkgz/routegroup/badge.svg?branch=master)](https://coveralls.io/github/go-pkgz/routegroup?branch=master) [![godoc](https://godoc.org/github.com/go-pkgz/routegroup?status.svg)](https://godoc.org/github.com/go-pkgz/routegroup)\n\n\n`routegroup` is a tiny Go package providing a lightweight wrapper for efficient route grouping and middleware integration with the standard `http.ServeMux`.\n\n## Features\n\n- Simple and intuitive API for route grouping and route mounting.\n- Lightweight, just about 100 LOC\n- Easy middleware integration for individual routes or groups of routes.\n- Seamless integration with Go's standard `http.ServeMux`.\n- Fully compatible with the `http.Handler` interface and can be used as a drop-in replacement for `http.ServeMux`.\n- No external dependencies.\n\n## Requirements\n\n- Go 1.23 or higher\n  *(This library uses `http.Request.Pattern` to make route patterns available to global middlewares and relies on the enhanced `http.ServeMux` routing behavior introduced in Go 1.22/1.23)*\n\n## Install and update\n\n`go get -u github.com/go-pkgz/routegroup`\n\n## Usage\n\n**Creating a New Route Group**\n\nTo start, create a new route group without a base path:\n\n```go\nfunc main() {\n    mux := http.NewServeMux()\n    group := routegroup.New(mux)\n}\n```\n\n**Adding Routes with Middleware**\n\nAdd routes to your group, optionally with middleware:\n\n```go\n    group.Use(loggingMiddleware, corsMiddleware)\n    group.Handle(\"/hello\", helloHandler)\n    group.Handle(\"/bye\", byeHandler)\n```\n**Creating a Nested Route Group**\n\nFor routes under a specific path prefix `Mount` method can be used to create a nested group:\n\n```go\n    apiGroup := routegroup.Mount(mux, \"/api\")\n    apiGroup.Use(loggingMiddleware, corsMiddleware)\n    apiGroup.Handle(\"/v1\", apiV1Handler)\n    apiGroup.Handle(\"/v2\", apiV2Handler)\n\n```\n\n**Complete Example**\n\nHere's a complete example demonstrating route grouping and middleware usage:\n\n```go\npackage main\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/go-pkgz/routegroup\"\n)\n\nfunc main() {\n\trouter := routegroup.New(http.NewServeMux())\n\trouter.Use(loggingMiddleware)\n\n\t// handle the /hello route\n\trouter.Handle(\"GET /hello\", helloHandler)\n\t\n\t// create a new group for the /api path\n\tapiRouter := router.Mount(\"/api\")\n\t// add middleware\n\tapiRouter.Use(loggingMiddleware, corsMiddleware)\n\n\t// route handling\n\tapiRouter.HandleFunc(\"GET /hello\", func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Write([]byte(\"Hello, API!\"))\n\t})\n\n\t// add another group with its own set of middlewares\n\tprotectedGroup := router.Group()\n\tprotectedGroup.Use(authMiddleware)\n\tprotectedGroup.HandleFunc(\"GET /protected\", func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Write([]byte(\"Protected API!\"))\n\t})\n\n\thttp.ListenAndServe(\":8080\", router)\n}\n```\n\n**Applying Middleware to Specific Routes**\n\nYou can also apply middleware to specific routes inside the group without modifying the group's middleware stack:\n\n```go\napiGroup.With(corsMiddleware, apiMiddleware).Handle(\"GET /hello\", helloHandler)\n```\n\n**Alternative Usage with `Route`**\n\nYou can also use the `Route` method to add routes and middleware in a single function call:\n\n```go\nrouter := routegroup.New(http.NewServeMux())\nrouter.Route(func(b *routegroup.Bundle) {\n    b.Use(loggingMiddleware, corsMiddleware)\n    b.Handle(\"GET /hello\", helloHandler)\n    b.Handle(\"GET /bye\", byeHandler)\n})\nhttp.ListenAndServe(\":8080\", router)\n```\n\nWhen called on the root bundle, `Route` automatically creates a new group to avoid accidentally modifying the root bundle's middleware stack. This means the middleware and routes defined inside the `Route` function are isolated from other routes on the root bundle.\n\nThe `Route` method can also be chained after `Mount` or `Group` for a more functional style:\n\n```go\nrouter := routegroup.New(http.NewServeMux())\nrouter.Group().Route(func(b *routegroup.Bundle) {\n    b.Use(loggingMiddleware, corsMiddleware)\n    b.Handle(\"GET /hello\", helloHandler)\n    b.Handle(\"GET /bye\", byeHandler)\n})\n```\n\n**Setting optional `NotFoundHandler`**\n\nIt is possible to set a custom `NotFoundHandler` for the group. This handler will be called when no other route matches the request:\n\n```go\ngroup.NotFoundHandler(func(w http.ResponseWriter, _ *http.Request) {\n    http.Error(w, \"404 page not found, something is wrong!\", http.StatusNotFound)\n}\n```\n\nIf a custom `NotFoundHandler` is not configured, `routegroup` will default to using the standard library behavior.\n\nNote on 405: In the current design, `routegroup` applies root-level middlewares to all requests at the top level without installing a catch‑all route. This preserves native `405 Method Not Allowed` responses from `http.ServeMux` when a path exists but a wrong method is used. A configured `NotFoundHandler` is only invoked when no route matches; it does not interfere with 405 handling. The custom `NotFoundHandler` will have the root bundle's global middlewares applied to it.\n\nLegacy note: `DisableNotFoundHandler()` is now a no‑op and preserved only for API compatibility.\n\n### Middleware Ordering\n\n- Call `Use(...)` before registering routes on the same bundle. Calling `Use` after any handler has been registered on that bundle will panic with a descriptive error.\n- Root bundle middlewares (added via `router.Use(...)`) are applied globally to all requests at serve time.\n- Group/bundle middlewares (added via `group.Use(...)`) apply to the routes registered on that bundle and its descendants, provided they are added before those routes.\n- `With(...)` returns a new bundle; you can add middlewares there first, then register routes. This is the preferred way to add scoped middlewares without affecting previously defined routes.\n\n**Important**: Route registration (HandleFunc, Handle, HandleFiles, etc.) should be done during initialization and not performed concurrently. The library is designed for typical usage where routes are registered at startup time in a single goroutine.\n\nExamples\n\nIncorrect: calling `Use` after routes on the same bundle (will panic)\n\n```go\nmux := http.NewServeMux()\nrouter := routegroup.New(mux)\n\nrouter.HandleFunc(\"/r\", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })\n\n// This will panic: Use called after routes were registered on this bundle\nrouter.Use(func(next http.Handler) http.Handler {\n    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n        // global header\n        w.Header().Set(\"X-Global\", \"true\")\n        next.ServeHTTP(w, r)\n    })\n})\n```\n\nAllowed: parent/root `Use` after child bundle routes\n\n```go\nmux := http.NewServeMux()\nrouter := routegroup.New(mux)\n\nchild := router.Group()\nchild.HandleFunc(\"/child\", func(w http.ResponseWriter, _ *http.Request) { w.Write([]byte(\"ok\")) })\n\n// Parent has not registered its own routes yet; this is allowed and will apply globally\nrouter.Use(func(next http.Handler) http.Handler {\n    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n        w.Header().Set(\"X-Parent\", \"true\")\n        next.ServeHTTP(w, r)\n    })\n})\n```\n\nPreferred: use `With` (or `Group`+`Use`) to attach scoped middleware before routes\n\n```go\nmux := http.NewServeMux()\nrouter := routegroup.New(mux)\n\n// Global middleware (optional), add before any root routes\nrouter.Use(loggingMiddleware)\n\n// Scoped middleware using With: returns a new bundle on which we can add routes\napi := router.With(authMiddleware)\napi.HandleFunc(\"GET /items\", itemsHandler)\napi.HandleFunc(\"POST /items\", createItem)\n\n// Or using Group + Use before routes\nadmin := router.Group()\nadmin.Use(adminOnly)\nadmin.HandleFunc(\"GET /dashboard\", dashboardHandler)\n```\n\n\n**Handling Root Paths Without Trailing Slashes**\n\nWhen working with mounted groups, you often need to handle requests to the group's root path without a trailing slash. For this purpose, `routegroup` provides the `HandleRoot` or `HandleRootFunc` methods:\n\n```go\n// Create mounted groups\napiGroup := router.Mount(\"/api\")\nv1Group := apiGroup.Mount(\"/v1\")\nusersGroup := v1Group.Mount(\"/users\")\n\n// Handle the root paths (no trailing slashes)\napiGroup.HandleRoot(\"GET\", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n    // This handles requests to \"/api\" (without trailing slash)\n    w.Write([]byte(\"API Documentation\"))\n}))\n\nusersGroup.HandleRoot(\"GET\", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n    // This handles requests to \"/api/v1/users\" (without trailing slash)\n    w.Write([]byte(\"List users\"))\n}))\n\n// Different HTTP methods can be handled separately\nusersGroup.HandleRoot(\"POST\", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n    // This handles POST requests to \"/api/v1/users\"\n    w.Write([]byte(\"Create user\"))\n}))\n```\n\nWhile it's also possible to handle such paths using a trailing slash pattern (`\"/\"`) with the regular `Handle` or `HandleFunc` methods, that approach results in a redirect from non-trailing slash URLs (e.g., `/api`) to the trailing slash version (e.g., `/api/`). The `HandleRoot` method avoids this redirect, providing a more direct response and avoiding an extra round-trip, which is especially important for non-GET requests or when clients don't automatically follow redirects.\n\n### Using derived groups\n\nIn some instances, it's practical to create an initial group that includes a set of middlewares, and then derive all other groups from it. This approach guarantees that every group incorporates a common set of middlewares as a foundation, allowing each to add its specific middlewares. To facilitate this scenario, `routegroup` offers both `Bundle.Group` and `Bundle.Mount` methods, and it also implements the `http.Handler` interface. The following example illustrates how to use derived groups:\n\n```go\n// create a new bundle with a base set of middlewares\n// note: the bundle is also http.Handler and can be passed to http.ListenAndServe\nrouter := routegroup.New(http.NewServeMux()) \nrouter.Use(loggingMiddleware, corsMiddleware)\n\n// add a new, derived group with its own set of middlewares\n// this group will inherit the middlewares from the base group\napiGroup := router.Group()\napiGroup.Use(apiMiddleware)\napiGroup.Handle(\"GET /hello\", helloHandler)\napiGroup.Handle(\"GET /bye\", byeHandler)\n\n// mount another group for the /admin path with its own set of middlewares, \n// using `Route` method to show the alternative usage.\n// this group will inherit the middlewares from the base group as well\nrouter.Mount(\"/admin\").Route(func(b *routegroup.Bundle) {\n    b.Use(adminMiddleware)\n    b.Handle(\"POST /do\", doHandler)\n})\n\n// start the server, passing the wrapped mux as the handler\nhttp.ListenAndServe(\":8080\", router)\n```\n### Wrap Function\n\nSometimes route's group is not necessary, and all you need is to apply middleware(s) directly to a single route. In this case, `routegroup` provides a `Wrap` function that can be used to wrap a single `http.Handler` with one or more middlewares. Here's an example:\n\n```go\nmux := http.NewServeMux()\nmux.HandleFunc(\"/hello\", routegroup.Wrap(helloHandler, loggingMiddleware, corsMiddleware))\nhttp.ListenAndServe(\":8080\", mux)\n```\n\n### 404 and 405 behavior\n\n`routegroup` applies the root bundle's middlewares to all requests at the top level. This keeps the standard library's matching logic intact:\n- Wrong method on an existing path returns `405 Method Not Allowed` (with an `Allow` header).\n- Unknown path returns `404 Not Found`.\n\nYou can optionally configure a custom 404 handler with `NotFoundHandler(fn)`. It will run only when no route matches and does not affect 405 handling. The custom handler will have global middlewares applied to it. The legacy `DisableNotFoundHandler()` is now a no‑op and kept only for compatibility.\n\n### HandleFiles helper\n\n`routegroup` provides a helper function `HandleFiles` that can be used to serve static files from a directory. The function is a thin wrapper around the standard `http.FileServer` and can be used to serve files from a specific directory. Here's an example:\n\n```go\n// serve static files from the \"assets/static\" directory\nrouter.HandleFiles(\"/static/\", http.Dir(\"assets/static\"))\n```\n\n## Real-world example\n\nHere's an example of how `routegroup` can be used in a real-world application. The following code snippet is taken from a web service that provides a set of routes for user authentication, session management, and user management. The service also serves static files from the \"assets/static\" embedded file system.\n\n```go\n\n// Routes returns http.Handler that handles all the routes for the Service.\n// It also serves static files from the \"assets/static\" directory.\n// The rootURL option sets prefix for the routes.\nfunc (s *Service) Routes() http.Handler {\n\trouter := routegroup.Mount(http.NewServeMux(), s.rootURL) // make a bundle with the rootURL base path\n\t// add common middlewares\n\trouter.Use(rest.Maybe(handlers.CompressHandler, func(*http.Request) bool { return !s.skipGZ }))\n\trouter.Use(rest.Throttle(s.limitActiveReqs))\n\trouter.Use(s.middleware.securityHeaders(s.skipSecurityHeaders))\n\n\t// prepare csrf middleware\n\tcsrfMiddleware := s.middleware.csrf(s.skipCSRFCheck)\n\n\t// add open routes\n\trouter.HandleFunc(\"GET /login\", s.loginPageHandler)\n\trouter.HandleFunc(\"POST /login\", s.loginCheckHandler)\n\trouter.HandleFunc(\"GET /logout\", s.logoutHandler)\n\n\t// add routes with auth middleware\n\trouter.Group().Route(func(auth *routegroup.Bundle) {\n\t\tauth.Use(s.middleware.Auth())\n\t\tauth.HandleFunc(\"GET /update\", s.pwdUpdateHandler)\n\t\tauth.With(csrfMiddleware).HandleFunc(\"PUT /update\", s.pwdUpdateHandler)\n\t})\n\n\t// add admin routes\n\trouter.Mount(\"/admin\").Route(func(admin *routegroup.Bundle) {\n\t\tadmin.Use(s.middleware.Auth(\"admin\"))\n\t\tadmin.Use(s.middleware.AdminOnly)\n\t\tadmin.HandleFunc(\"GET /\", s.admin.renderHandler)\n\t\tadmin.With(csrfMiddleware).Route(func(csrf *routegroup.Bundle) {\n\t\t\tcsrf.HandleFunc(\"DELETE /sessions\", s.admin.deleteSessionsHandler)\n\t\t\tcsrf.HandleFunc(\"POST /user\", s.admin.addUserHandler)\n\t\t\tcsrf.HandleFunc(\"DELETE /user\", s.admin.deleteUserHandler)\n\t\t})\n\t})\n\n\trouter.HandleFunc(\"GET /static/*\", s.fileServerHandlerFunc()) // serve static files\n\treturn router\n}\n\n// fileServerHandlerFunc returns http.HandlerFunc that serves static files from the \"assets/static\" directory.\n// prefix is set by the rootURL option.\nfunc (s *Service) fileServerHandlerFunc() http.HandlerFunc {\n    staticFS, err := fs.Sub(assets, \"assets/static\") // error is always nil\n    if err != nil {\n        panic(err) // should never happen we load from embedded FS\n    }\n    return func(w http.ResponseWriter, r *http.Request) {\n        webFS := http.StripPrefix(s.rootURL+\"/static/\", http.FileServer(http.FS(staticFS)))\n        webFS.ServeHTTP(w, r)\n    }\n}\n```\n\n## Contributing\n\nContributions to `routegroup` are welcome! Please submit a pull request or open an issue for any bugs or feature requests.\n\n## License\n\n`routegroup` is available under the MIT license. See the [LICENSE](https://github.com/go-pkgz/routegroup/blob/master/LICENSE) file for more info.\n"
  },
  {
    "path": "fileserver_test.go",
    "content": "package routegroup_test\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/go-pkgz/routegroup\"\n)\n\nfunc TestStaticFileServer(t *testing.T) {\n\tdir := t.TempDir()\n\n\t// create test file structure\n\tcontent := []byte(\"static file content\")\n\terr := os.WriteFile(filepath.Join(dir, \"test.txt\"), content, 0o600)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\terr = os.WriteFile(filepath.Join(dir, \"index.html\"), []byte(\"index content\"), 0o600)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// create subdirectory\n\tsubDir := filepath.Join(dir, \"sub\")\n\tif err = os.Mkdir(subDir, 0o750); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tsubContent := []byte(\"sub file content\")\n\terr = os.WriteFile(filepath.Join(subDir, \"sub.txt\"), subContent, 0o600)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tt.Run(\"serve files from root path with HEAD\", func(t *testing.T) {\n\t\trouter := routegroup.New(http.NewServeMux())\n\t\trouter.HandleFiles(\"/\", http.Dir(dir))\n\n\t\tsrv := httptest.NewServer(router)\n\t\tdefer srv.Close()\n\n\t\t// test GET request\n\t\tresp, err := http.Get(srv.URL + \"/test.txt\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\tt.Errorf(\"GET - got status %d, want %d\", resp.StatusCode, http.StatusOK)\n\t\t}\n\t\tif !bytes.Equal(body, content) {\n\t\t\tt.Errorf(\"GET - got body %q, want %q\", body, content)\n\t\t}\n\n\t\t// test HEAD request\n\t\treq, err := http.NewRequest(http.MethodHead, srv.URL+\"/test.txt\", http.NoBody)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tresp, err = http.DefaultClient.Do(req)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tbody, err = io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\tt.Errorf(\"HEAD - got status %d, want %d\", resp.StatusCode, http.StatusOK)\n\t\t}\n\t\tif len(body) != 0 {\n\t\t\tt.Errorf(\"HEAD - should have no body, got %d bytes\", len(body))\n\t\t}\n\t\tif cl := resp.Header.Get(\"Content-Length\"); cl != fmt.Sprint(len(content)) {\n\t\t\tt.Errorf(\"HEAD - got Content-Length %s, want %d\", cl, len(content))\n\t\t}\n\t})\n\n\tt.Run(\"serve files from /files/ prefix\", func(t *testing.T) {\n\t\trouter := routegroup.New(http.NewServeMux())\n\t\trouter.HandleFiles(\"/files\", http.Dir(dir))\n\n\t\tsrv := httptest.NewServer(router)\n\t\tdefer srv.Close()\n\n\t\t// test GET request\n\t\tresp, err := http.Get(srv.URL + \"/files/test.txt\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\tt.Errorf(\"GET - got status %d, want %d\", resp.StatusCode, http.StatusOK)\n\t\t}\n\t\tif !bytes.Equal(body, content) {\n\t\t\tt.Errorf(\"GET - got body %q, want %q\", body, content)\n\t\t}\n\n\t\t// test HEAD request\n\t\treq, err := http.NewRequest(http.MethodHead, srv.URL+\"/files/test.txt\", http.NoBody)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tresp, err = http.DefaultClient.Do(req)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tbody, err = io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\tt.Errorf(\"HEAD - got status %d, want %d\", resp.StatusCode, http.StatusOK)\n\t\t}\n\t\tif len(body) != 0 {\n\t\t\tt.Errorf(\"HEAD - should have no body, got %d bytes\", len(body))\n\t\t}\n\t\tif cl := resp.Header.Get(\"Content-Length\"); cl != fmt.Sprint(len(content)) {\n\t\t\tt.Errorf(\"HEAD - got Content-Length %s, want %d\", cl, len(content))\n\t\t}\n\t})\n\n\tt.Run(\"serve files from mounted group\", func(t *testing.T) {\n\t\trouter := routegroup.New(http.NewServeMux())\n\t\tassets := router.Mount(\"/assets\")\n\t\tassets.HandleFiles(\"/\", http.Dir(dir))\n\n\t\tsrv := httptest.NewServer(router)\n\t\tdefer srv.Close()\n\n\t\t// test GET request\n\t\tresp, err := http.Get(srv.URL + \"/assets/test.txt\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\tt.Errorf(\"GET - got status %d, want %d\", resp.StatusCode, http.StatusOK)\n\t\t}\n\t\tif !bytes.Equal(body, content) {\n\t\t\tt.Errorf(\"GET - got body %q, want %q\", body, content)\n\t\t}\n\n\t\t// test HEAD request\n\t\treq, err := http.NewRequest(http.MethodHead, srv.URL+\"/assets/test.txt\", http.NoBody)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tresp, err = http.DefaultClient.Do(req)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tbody, err = io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\tt.Errorf(\"HEAD - got status %d, want %d\", resp.StatusCode, http.StatusOK)\n\t\t}\n\t\tif len(body) != 0 {\n\t\t\tt.Errorf(\"HEAD - should have no body, got %d bytes\", len(body))\n\t\t}\n\t\tif cl := resp.Header.Get(\"Content-Length\"); cl != fmt.Sprint(len(content)) {\n\t\t\tt.Errorf(\"HEAD - got Content-Length %s, want %d\", cl, len(content))\n\t\t}\n\t})\n}\n\nfunc TestDirectFileServerHandle(t *testing.T) {\n\tdir := t.TempDir()\n\n\tcontent := []byte(\"static file content\")\n\terr := os.WriteFile(filepath.Join(dir, \"test.txt\"), content, 0o600)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tt.Run(\"raw Handle without strip\", func(t *testing.T) {\n\t\trouter := routegroup.New(http.NewServeMux())\n\t\trouter.Handle(\"/files/\", http.FileServer(http.Dir(dir))) // without StripPrefix!\n\n\t\tsrv := httptest.NewServer(router)\n\t\tdefer srv.Close()\n\n\t\t// test GET request - should fail as we need StripPrefix\n\t\tresp, err := http.Get(srv.URL + \"/files/test.txt\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tif resp.StatusCode != http.StatusNotFound {\n\t\t\tt.Errorf(\"expected 404 without StripPrefix, got %d\", resp.StatusCode)\n\t\t}\n\n\t\t// test HEAD request - should also fail\n\t\treq, err := http.NewRequest(http.MethodHead, srv.URL+\"/files/test.txt\", http.NoBody)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tresp, err = http.DefaultClient.Do(req)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tif resp.StatusCode != http.StatusNotFound {\n\t\t\tt.Errorf(\"HEAD - expected 404 without StripPrefix, got %d\", resp.StatusCode)\n\t\t}\n\t})\n\n\tt.Run(\"Handle with strip prefix\", func(t *testing.T) {\n\t\trouter := routegroup.New(http.NewServeMux())\n\t\trouter.Handle(\"/files/\", http.StripPrefix(\"/files/\", http.FileServer(http.Dir(dir))))\n\n\t\tsrv := httptest.NewServer(router)\n\t\tdefer srv.Close()\n\n\t\t// test GET request\n\t\tresp, err := http.Get(srv.URL + \"/files/test.txt\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\tt.Errorf(\"GET - got status %d, want %d\", resp.StatusCode, http.StatusOK)\n\t\t}\n\t\tif !bytes.Equal(body, content) {\n\t\t\tt.Errorf(\"GET - got body %q, want %q\", body, content)\n\t\t}\n\n\t\t// test HEAD request\n\t\treq, err := http.NewRequest(http.MethodHead, srv.URL+\"/files/test.txt\", http.NoBody)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tresp, err = http.DefaultClient.Do(req)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tbody, err = io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\tt.Errorf(\"HEAD - got status %d, want %d\", resp.StatusCode, http.StatusOK)\n\t\t}\n\t\tif len(body) != 0 {\n\t\t\tt.Errorf(\"HEAD - should have no body, got %d bytes\", len(body))\n\t\t}\n\t\tif cl := resp.Header.Get(\"Content-Length\"); cl != fmt.Sprint(len(content)) {\n\t\t\tt.Errorf(\"HEAD - got Content-Length %s, want %d\", cl, len(content))\n\t\t}\n\t})\n\n\tt.Run(\"Handle with mounted group\", func(t *testing.T) {\n\t\trouter := routegroup.New(http.NewServeMux())\n\t\tapi := router.Mount(\"/api\")\n\t\tapi.Handle(\"/static/\", http.StripPrefix(\"/api/static/\", http.FileServer(http.Dir(dir))))\n\n\t\tsrv := httptest.NewServer(router)\n\t\tdefer srv.Close()\n\n\t\t// test GET request\n\t\tresp, err := http.Get(srv.URL + \"/api/static/test.txt\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\tt.Errorf(\"GET - got status %d, want %d\", resp.StatusCode, http.StatusOK)\n\t\t}\n\t\tif !bytes.Equal(body, content) {\n\t\t\tt.Errorf(\"GET - got body %q, want %q\", body, content)\n\t\t}\n\n\t\t// test HEAD request\n\t\treq, err := http.NewRequest(http.MethodHead, srv.URL+\"/api/static/test.txt\", http.NoBody)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tresp, err = http.DefaultClient.Do(req)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tbody, err = io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\tt.Errorf(\"HEAD - got status %d, want %d\", resp.StatusCode, http.StatusOK)\n\t\t}\n\t\tif len(body) != 0 {\n\t\t\tt.Errorf(\"HEAD - should have no body, got %d bytes\", len(body))\n\t\t}\n\t\tif cl := resp.Header.Get(\"Content-Length\"); cl != fmt.Sprint(len(content)) {\n\t\t\tt.Errorf(\"HEAD - got Content-Length %s, want %d\", cl, len(content))\n\t\t}\n\t})\n}\n\nfunc TestFileServerWithMiddleware(t *testing.T) {\n\tdir := t.TempDir()\n\n\tcontent := []byte(\"static file content\")\n\terr := os.WriteFile(filepath.Join(dir, \"test.txt\"), content, 0o600)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tt.Run(\"root path with middleware\", func(t *testing.T) {\n\t\trouter := routegroup.New(http.NewServeMux())\n\t\trouter.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tw.Header().Set(\"X-Root-MW\", \"called\")\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t})\n\t\trouter.HandleFiles(\"/\", http.Dir(dir))\n\n\t\tsrv := httptest.NewServer(router)\n\t\tdefer srv.Close()\n\n\t\t// test GET request\n\t\tresp, err := http.Get(srv.URL + \"/test.txt\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tif mw := resp.Header.Get(\"X-Root-MW\"); mw != \"called\" {\n\t\t\tt.Errorf(\"middleware not called, got header %q\", mw)\n\t\t}\n\n\t\t// test HEAD request\n\t\treq, err := http.NewRequest(http.MethodHead, srv.URL+\"/test.txt\", http.NoBody)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tresp, err = http.DefaultClient.Do(req)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tif mw := resp.Header.Get(\"X-Root-MW\"); mw != \"called\" {\n\t\t\tt.Errorf(\"middleware not called for HEAD, got header %q\", mw)\n\t\t}\n\t})\n\n\tt.Run(\"prefixed path with middleware\", func(t *testing.T) {\n\t\trouter := routegroup.New(http.NewServeMux())\n\t\trouter.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tw.Header().Set(\"X-Prefix-MW\", \"called\")\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t})\n\t\trouter.HandleFiles(\"/files\", http.Dir(dir))\n\n\t\tsrv := httptest.NewServer(router)\n\t\tdefer srv.Close()\n\n\t\tresp, err := http.Get(srv.URL + \"/files/test.txt\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tif mw := resp.Header.Get(\"X-Prefix-MW\"); mw != \"called\" {\n\t\t\tt.Errorf(\"middleware not called, got header %q\", mw)\n\t\t}\n\t})\n\n\tt.Run(\"mounted path with chained middleware\", func(t *testing.T) {\n\t\trouter := routegroup.New(http.NewServeMux())\n\t\trouter.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tw.Header().Set(\"X-Root-MW\", \"called\")\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t})\n\n\t\tassets := router.Mount(\"/assets\")\n\t\tassets.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tw.Header().Set(\"X-Assets-MW\", \"called\")\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t})\n\t\tassets.HandleFiles(\"/\", http.Dir(dir))\n\n\t\tsrv := httptest.NewServer(router)\n\t\tdefer srv.Close()\n\n\t\t// test both middleware being called\n\t\tresp, err := http.Get(srv.URL + \"/assets/test.txt\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tif mw := resp.Header.Get(\"X-Root-MW\"); mw != \"called\" {\n\t\t\tt.Errorf(\"root middleware not called, got header %q\", mw)\n\t\t}\n\t\tif mw := resp.Header.Get(\"X-Assets-MW\"); mw != \"called\" {\n\t\t\tt.Errorf(\"assets middleware not called, got header %q\", mw)\n\t\t}\n\n\t\t// test 404 path still triggers middleware\n\t\tresp, err = http.Get(srv.URL + \"/assets/notfound.txt\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tif resp.StatusCode != http.StatusNotFound {\n\t\t\tt.Errorf(\"got status %d, want %d\", resp.StatusCode, http.StatusNotFound)\n\t\t}\n\t\tif mw := resp.Header.Get(\"X-Root-MW\"); mw != \"called\" {\n\t\t\tt.Errorf(\"root middleware not called for 404, got header %q\", mw)\n\t\t}\n\t\tif mw := resp.Header.Get(\"X-Assets-MW\"); mw != \"called\" {\n\t\t\tt.Errorf(\"assets middleware not called for 404, got header %q\", mw)\n\t\t}\n\t})\n\n\tt.Run(\"direct Handle with middleware\", func(t *testing.T) {\n\t\trouter := routegroup.New(http.NewServeMux())\n\t\trouter.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tw.Header().Set(\"X-Direct-MW\", \"called\")\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t})\n\t\trouter.Handle(\"/files/\", http.StripPrefix(\"/files/\", http.FileServer(http.Dir(dir))))\n\n\t\tsrv := httptest.NewServer(router)\n\t\tdefer srv.Close()\n\n\t\tresp, err := http.Get(srv.URL + \"/files/test.txt\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tif mw := resp.Header.Get(\"X-Direct-MW\"); mw != \"called\" {\n\t\t\tt.Errorf(\"middleware not called, got header %q\", mw)\n\t\t}\n\t})\n}\n\nfunc TestMixedHandlers(t *testing.T) {\n\tdir := t.TempDir()\n\n\t// create static files\n\tcontent := []byte(\"static file content\")\n\terr := os.WriteFile(filepath.Join(dir, \"test.txt\"), content, 0o600)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\trouter := routegroup.New(http.NewServeMux())\n\n\t// setup regular and file handlers in various combinations\n\trouter.HandleFunc(\"GET /api/info\", func(w http.ResponseWriter, r *http.Request) {\n\t\t_, _ = w.Write([]byte(\"api info\"))\n\t})\n\trouter.HandleFiles(\"/public\", http.Dir(dir))\n\n\t// setup api group with mixed handlers\n\tapi := router.Mount(\"/v1\")\n\tapi.HandleFunc(\"GET /data\", func(w http.ResponseWriter, r *http.Request) {\n\t\t_, _ = w.Write([]byte(\"api data\"))\n\t})\n\tapi.HandleFiles(\"/static\", http.Dir(dir))\n\n\t// setup admin group with both types and middleware\n\tadmin := router.Mount(\"/admin\")\n\tadmin.Use(func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tw.Header().Set(\"X-Admin\", \"true\")\n\t\t\tnext.ServeHTTP(w, r)\n\t\t})\n\t})\n\tadmin.HandleFunc(\"GET /users\", func(w http.ResponseWriter, r *http.Request) {\n\t\t_, _ = w.Write([]byte(\"admin users\"))\n\t})\n\tadmin.HandleFiles(\"/assets\", http.Dir(dir))\n\n\tsrv := httptest.NewServer(router)\n\tdefer srv.Close()\n\n\ttests := []struct {\n\t\tname           string\n\t\tpath           string\n\t\texpectedStatus int\n\t\texpectedBody   string\n\t\texpectedHeader string // for middleware check\n\t}{\n\t\t{\"api info endpoint\", \"/api/info\", http.StatusOK, \"api info\", \"\"},\n\t\t{\"public static file\", \"/public/test.txt\", http.StatusOK, \"static file content\", \"\"},\n\t\t{\"v1 api endpoint\", \"/v1/data\", http.StatusOK, \"api data\", \"\"},\n\t\t{\"v1 static file\", \"/v1/static/test.txt\", http.StatusOK, \"static file content\", \"\"},\n\t\t{\"admin endpoint\", \"/admin/users\", http.StatusOK, \"admin users\", \"true\"},\n\t\t{\"admin static file\", \"/admin/assets/test.txt\", http.StatusOK, \"static file content\", \"true\"},\n\t\t{\"non-existent api path\", \"/api/notfound\", http.StatusNotFound, \"404 page not found\\n\", \"\"},\n\t\t{\"non-existent static file\", \"/public/notfound.txt\", http.StatusNotFound, \"404 page not found\\n\", \"\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresp, err := http.Get(srv.URL + tt.path)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\t\t\tdefer resp.Body.Close()\n\n\t\t\tbody, err := io.ReadAll(resp.Body)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\n\t\t\tif resp.StatusCode != tt.expectedStatus {\n\t\t\t\tt.Errorf(\"got status %d, want %d\", resp.StatusCode, tt.expectedStatus)\n\t\t\t}\n\n\t\t\tif string(body) != tt.expectedBody {\n\t\t\t\tt.Errorf(\"got body %q, want %q\", string(body), tt.expectedBody)\n\t\t\t}\n\n\t\t\tif tt.expectedHeader != \"\" {\n\t\t\t\tif h := resp.Header.Get(\"X-Admin\"); h != tt.expectedHeader {\n\t\t\t\t\tt.Errorf(\"got X-Admin header %q, want %q\", h, tt.expectedHeader)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestIssue12StaticAndIndex(t *testing.T) {\n\tdir := t.TempDir()\n\terr := os.WriteFile(filepath.Join(dir, \"test.txt\"), []byte(\"static content\"), 0o600)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\trouter := routegroup.New(http.NewServeMux())\n\trouter.Route(func(base *routegroup.Bundle) {\n\t\tbase.Handle(\"/\", http.FileServer(http.Dir(dir)))\n\t\tbase.HandleFunc(\"GET /{$}\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\t_, _ = w.Write([]byte(\"index page\"))\n\t\t})\n\t\tbase.HandleFunc(\"GET /login\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\t_, _ = w.Write([]byte(\"login page\"))\n\t\t})\n\t})\n\n\tsrv := httptest.NewServer(router)\n\tdefer srv.Close()\n\n\tt.Run(\"serve static file\", func(t *testing.T) {\n\t\tresp, err := http.Get(srv.URL + \"/test.txt\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\tif got := string(body); got != \"static content\" {\n\t\t\tt.Errorf(\"got %q, want static content\", got)\n\t\t}\n\t})\n\n\tt.Run(\"serve index\", func(t *testing.T) {\n\t\tresp, err := http.Get(srv.URL + \"/\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\tif got := string(body); got != \"index page\" {\n\t\t\tt.Errorf(\"got %q, want index page\", got)\n\t\t}\n\t})\n\n\tt.Run(\"serve login page\", func(t *testing.T) {\n\t\tresp, err := http.Get(srv.URL + \"/login\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\tif got := string(body); got != \"login page\" {\n\t\t\tt.Errorf(\"got %q, want login page\", got)\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "go.mod",
    "content": "module github.com/go-pkgz/routegroup\n\ngo 1.23\n"
  },
  {
    "path": "go.sum",
    "content": ""
  },
  {
    "path": "group.go",
    "content": "// Package routegroup provides a way to group routes and applies middleware to them.\n// Works with the standard library's http.ServeMux.\npackage routegroup\n\nimport (\n\t\"net/http\"\n\t\"regexp\"\n\t\"strings\"\n)\n\n// Bundle represents a group of routes with associated middleware.\ntype Bundle struct {\n\tmux         *http.ServeMux                    // the underlying mux to register the routes to\n\tbasePath    string                            // base path for the group\n\tmiddlewares []func(http.Handler) http.Handler // middlewares stack\n\n\t// optional custom 404 handler\n\tnotFound http.HandlerFunc\n\n\t// root points to the root bundle for global middleware application.\n\t// for the root bundle, root == nil.\n\troot *Bundle\n\n\t// routesLocked indicates that routes have been registered on the root bundle\n\t// and no further root-level middlewares may be added.\n\troutesLocked bool\n\n\t// rootCount captures how many root middlewares were present when this bundle\n\t// was created. Used to avoid double-applying root middlewares for per-route wrapping.\n\trootCount int\n}\n\n// New creates a new Group.\nfunc New(mux *http.ServeMux) *Bundle {\n\treturn &Bundle{mux: mux}\n}\n\n// Mount creates a new group with a specified base path.\nfunc Mount(mux *http.ServeMux, basePath string) *Bundle {\n\treturn &Bundle{mux: mux, basePath: basePath}\n}\n\n// ServeHTTP implements the http.Handler interface\nfunc (b *Bundle) ServeHTTP(w http.ResponseWriter, r *http.Request) {\n\t// resolve the root bundle (where global middlewares live).\n\troot := b\n\tif b.root != nil {\n\t\troot = b.root\n\t}\n\n\t// get the handler and pattern for this request\n\t_, pattern := b.mux.Handler(r)\n\n\t// if a pattern was found, create a shallow copy of the request with the pattern set\n\t// this allows global middlewares to see the pattern before mux.ServeHTTP is called\n\tif pattern != \"\" {\n\t\tr2 := *r\n\t\tr2.Pattern = pattern\n\t\tr = &r2\n\t}\n\n\t// create a handler that will let the mux do its routing (including setting path parameters)\n\t// but intercept 404s to use custom handler if provided\n\tmuxHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif pattern == \"\" && root.notFound != nil {\n\t\t\t// no route matched, need to check if it's a true 404 or a 405\n\t\t\t// probe the mux to see what status it would return\n\t\t\tprobe := &statusRecorder{status: http.StatusOK}\n\t\t\tb.mux.ServeHTTP(probe, r)\n\n\t\t\t// if mux wants to return 405 (Method Not Allowed), let it handle the request\n\t\t\t// to preserve the proper 405 response and Allow header\n\t\t\tif probe.status == http.StatusMethodNotAllowed {\n\t\t\t\tb.mux.ServeHTTP(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// it's a true 404, use custom handler\n\t\t\troot.notFound.ServeHTTP(w, r)\n\t\t\treturn\n\t\t}\n\t\t// let the mux handle the request normally (this sets path parameters)\n\t\tb.mux.ServeHTTP(w, r)\n\t})\n\n\t// apply root (global) middlewares around the mux handler and serve the request.\n\troot.wrapGlobal(muxHandler).ServeHTTP(w, r)\n}\n\n// Group creates a new group with the same middleware stack as the original on top of the existing bundle.\nfunc (b *Bundle) Group() *Bundle {\n\treturn b.clone() // copy the middlewares to avoid modifying the original\n}\n\n// Mount creates a new group with a specified base path on top of the existing bundle.\nfunc (b *Bundle) Mount(basePath string) *Bundle {\n\tg := b.clone() // copy the middlewares to avoid modifying the original\n\tg.basePath += basePath\n\treturn g\n}\n\n// Use adds middleware(s) to the Group.\n// Middlewares are executed in the order they are added.\n// Note: Root-level middlewares (added to the root bundle) have access to the matched\n// route pattern via r.Pattern, but execute before path parameters are parsed.\n// Therefore, r.PathValue() will return empty strings in root middlewares.\n// Middlewares on mounted groups execute after routing and have full access to path values.\nfunc (b *Bundle) Use(middleware func(http.Handler) http.Handler, more ...func(http.Handler) http.Handler) {\n\t// disallow adding middlewares after any routes have been registered on this bundle.\n\tif b.routesLocked {\n\t\tpanic(\"routegroup: Use called after routes were registered on this bundle; add middlewares before registering routes or use Group/With for scoped middleware\")\n\t}\n\tb.middlewares = append(b.middlewares, middleware)\n\tb.middlewares = append(b.middlewares, more...)\n}\n\n// With adds new middleware(s) to the Group and returns a new Group with the updated middleware stack.\n// The With method is similar to Use, but instead of modifying the current Group,\n// it returns a new Group instance with the added middleware(s).\n// This allows for creating chain of middleware without affecting the original Group.\nfunc (b *Bundle) With(middleware func(http.Handler) http.Handler, more ...func(http.Handler) http.Handler) *Bundle {\n\tnewMiddlewares := make([]func(http.Handler) http.Handler, len(b.middlewares), len(b.middlewares)+len(more)+1)\n\tcopy(newMiddlewares, b.middlewares)\n\tnewMiddlewares = append(newMiddlewares, middleware)\n\tnewMiddlewares = append(newMiddlewares, more...)\n\t// preserve root pointer and rootCount\n\tnb := &Bundle{mux: b.mux, basePath: b.basePath, middlewares: newMiddlewares, root: b.root, rootCount: b.rootCount}\n\tif nb.root == nil {\n\t\t// b is the root, so all b's middlewares are root middlewares\n\t\tnb.root = b\n\t\tnb.rootCount = len(b.middlewares)\n\t}\n\treturn nb\n}\n\n// Handle adds a new route to the Group's mux, applying all middlewares to the handler.\nfunc (b *Bundle) Handle(pattern string, handler http.Handler) {\n\tb.lockRoot() // lock root on first route registration\n\n\t// for file server paths (ending with /), preserve the pattern as-is\n\tif strings.HasSuffix(pattern, \"/\") {\n\t\tfullPath := b.basePath + pattern\n\t\tb.mux.Handle(fullPath, b.wrapMiddleware(handler))\n\t\treturn\n\t}\n\tb.register(pattern, handler.ServeHTTP)\n}\n\n// HandleFiles is a helper to serve static files from a directory\nfunc (b *Bundle) HandleFiles(pattern string, root http.FileSystem) {\n\tb.lockRoot() // lock root on first route registration\n\n\t// normalize pattern to always have trailing slash\n\tif !strings.HasSuffix(pattern, \"/\") {\n\t\tpattern += \"/\"\n\t}\n\n\t// build the full path for registration\n\tfullPath := b.basePath + pattern\n\n\tif pattern == \"/\" && b.basePath == \"\" {\n\t\t// root case - serve directly without stripping\n\t\tb.mux.Handle(\"/\", b.wrapMiddleware(http.FileServer(root)))\n\t\treturn\n\t}\n\n\t// for both mounted groups and prefixed paths, strip the fullPath\n\thandler := http.StripPrefix(strings.TrimSuffix(fullPath, \"/\"), http.FileServer(root))\n\tb.mux.Handle(fullPath, b.wrapMiddleware(handler))\n}\n\n// HandleFunc registers the handler function for the given pattern to the Group's mux.\n// The handler is wrapped with the Group's middlewares.\nfunc (b *Bundle) HandleFunc(pattern string, handler http.HandlerFunc) {\n\tb.register(pattern, handler)\n}\n\n// Handler returns the handler and the pattern that matches the request.\n// It always returns a non-nil handler, see http.ServeMux.Handler documentation for details.\nfunc (b *Bundle) Handler(r *http.Request) (h http.Handler, pattern string) {\n\treturn b.mux.Handler(r)\n}\n\n// DisableNotFoundHandler used to disable auto-registration of a catch-all 404.\n//\n// Deprecated: now a no-op retained for API compatibility.\nfunc (b *Bundle) DisableNotFoundHandler() {}\n\n// NotFoundHandler sets a custom handler for any unmatched routes (404 responses).\n// Note: This handler is only used for true 404s. Requests to valid paths with\n// incorrect HTTP methods will still return 405 Method Not Allowed with Allow header.\nfunc (b *Bundle) NotFoundHandler(handler http.HandlerFunc) {\n\t// always set on the root bundle so custom 404 works regardless of which bundle serves.\n\tif b.root != nil {\n\t\tb.root.notFound = handler\n\t\treturn\n\t}\n\tb.notFound = handler\n}\n\n// matches non-space characters, spaces, then anything, i.e. \"GET /path/to/resource\"\nvar reGo122 = regexp.MustCompile(`^(\\S+)\\s+(.+)$`)\n\nfunc (b *Bundle) register(pattern string, handler http.HandlerFunc) {\n\tb.lockRoot() // lock root on first route registration\n\tmatches := reGo122.FindStringSubmatch(pattern)\n\tvar path, method string\n\tif len(matches) > 2 { // path in the form \"GET /path/to/resource\"\n\t\tmethod = matches[1]\n\t\tpath = matches[2]\n\t\tpattern = method + \" \" + b.basePath + path\n\t} else { // path is just \"/path/to/resource\"\n\t\tpath = pattern\n\t\tpattern = b.basePath + pattern\n\t\t// method is not set intentionally here, the request pattern had no method part\n\t}\n\t// if the pattern is the root path on / change it to /{$}\n\t// this keeps handling the root request without becoming a catch-all\n\tif pattern == \"/\" || path == \"/\" {\n\t\tif method != \"\" { // preserve the method part if it was set\n\t\t\tpattern = method + \" \" + b.basePath + \"/{$}\"\n\t\t} else {\n\t\t\tpattern = b.basePath + \"/{$}\" // no method part, just the path\n\t\t}\n\t}\n\tb.mux.HandleFunc(pattern, b.wrapMiddleware(handler).ServeHTTP)\n}\n\n// Route allows for configuring the Group inside the configureFn function.\n// When called on the root bundle, it automatically creates a new group to avoid\n// accidentally modifying the root bundle's middleware stack.\nfunc (b *Bundle) Route(configureFn func(*Bundle)) {\n\t// if called on root bundle, auto-create a group for better UX\n\tif b.root == nil {\n\t\tchild := b.Group()\n\t\tconfigureFn(child)\n\t\t// if child registered routes, lock root too to prevent Use() after routes\n\t\tif child.routesLocked {\n\t\t\tb.routesLocked = true\n\t\t}\n\t\treturn\n\t}\n\tconfigureFn(b)\n}\n\n// HandleRoot adds a handler for the group's root path without trailing slash.\n// This avoids the 301 redirect that would occur with a \"/\" pattern.\n// Method parameter can be empty to register for all HTTP methods.\nfunc (b *Bundle) HandleRoot(method string, handler http.Handler) {\n\tb.lockRoot() // lock root on first route registration\n\n\t// for empty base path, use \"/\" to match the root\n\tpattern := b.basePath\n\tif pattern == \"\" {\n\t\tpattern = \"/\"\n\t}\n\n\t// add method if specified\n\tif method != \"\" {\n\t\tpattern = method + \" \" + pattern\n\t}\n\n\tb.mux.Handle(pattern, b.wrapMiddleware(handler))\n}\n\n// HandleRootFunc is like HandleRoot but takes a handler function.\nfunc (b *Bundle) HandleRootFunc(method string, handler http.HandlerFunc) {\n\tb.lockRoot() // lock root on first route registration\n\n\t// for empty base path, use \"/\" to match the root\n\tpattern := b.basePath\n\tif pattern == \"\" {\n\t\tpattern = \"/\"\n\t}\n\n\t// add method if specified\n\tif method != \"\" {\n\t\tpattern = method + \" \" + pattern\n\t}\n\n\tb.mux.HandleFunc(pattern, b.wrapMiddleware(handler).ServeHTTP)\n}\n\n// wrapMiddleware applies the registered middlewares to a handler.\nfunc (b *Bundle) wrapMiddleware(handler http.Handler) http.Handler {\n\t// root bundle: don't apply middlewares here, they're applied globally in ServeHTTP\n\tif b.root == nil {\n\t\treturn handler\n\t}\n\n\t// child bundle: apply only middlewares added after mounting (exclude inherited root middlewares)\n\tstart := b.rootCount\n\tif start > len(b.middlewares) {\n\t\tstart = len(b.middlewares) // safety: ensure start doesn't exceed bounds\n\t}\n\n\tfor i := len(b.middlewares) - 1; i >= start; i-- {\n\t\thandler = b.middlewares[i](handler)\n\t}\n\treturn handler\n}\n\nfunc (b *Bundle) clone() *Bundle {\n\tmiddlewares := make([]func(http.Handler) http.Handler, len(b.middlewares))\n\tcopy(middlewares, b.middlewares)\n\t// preserve root pointer and rootCount\n\tnb := &Bundle{mux: b.mux, basePath: b.basePath, middlewares: middlewares, root: b.root, rootCount: b.rootCount}\n\tif nb.root == nil {\n\t\t// b is the root, so all b's middlewares are root middlewares\n\t\tnb.root = b\n\t\tnb.rootCount = len(b.middlewares)\n\t}\n\treturn nb\n}\n\n// Wrap directly wraps the handler with the provided middleware(s).\nfunc Wrap(handler http.Handler, mw1 func(http.Handler) http.Handler, mws ...func(http.Handler) http.Handler) http.Handler {\n\tfor i := len(mws) - 1; i >= 0; i-- {\n\t\thandler = mws[i](handler)\n\t}\n\treturn mw1(handler) // apply the first middleware\n}\n\n// wrapGlobal applies only the root bundle's middlewares to the provided handler.\nfunc (b *Bundle) wrapGlobal(handler http.Handler) http.Handler {\n\t// resolve root bundle\n\troot := b\n\tif b.root != nil {\n\t\troot = b.root\n\t}\n\tfor i := len(root.middlewares) - 1; i >= 0; i-- {\n\t\thandler = root.middlewares[i](handler)\n\t}\n\treturn handler\n}\n\n// lockRoot marks this bundle as having registered routes.\nfunc (b *Bundle) lockRoot() { b.routesLocked = true }\n\n// statusRecorder is a minimal ResponseWriter that only records the status code.\n// Used to probe what status the mux would return without actually writing a response.\ntype statusRecorder struct {\n\tstatus int\n}\n\nfunc (r *statusRecorder) Header() http.Header {\n\treturn make(http.Header)\n}\n\nfunc (r *statusRecorder) Write([]byte) (int, error) {\n\treturn 0, nil\n}\n\nfunc (r *statusRecorder) WriteHeader(status int) {\n\tr.status = status\n}\n"
  },
  {
    "path": "group_test.go",
    "content": "package routegroup_test\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"reflect\"\n\t\"strings\"\n\t\"sync\"\n\t\"testing\"\n\n\t\"github.com/go-pkgz/routegroup\"\n)\n\n// testMiddleware is simple middleware for testing purposes.\nfunc testMiddleware(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tfmt.Println(\"Test middleware\")\n\t\tw.Header().Add(\"X-Test-Middleware\", \"true\")\n\t\tnext.ServeHTTP(w, r)\n\t})\n}\n\nfunc TestGroupMiddleware(t *testing.T) {\n\tgroup := routegroup.New(http.NewServeMux())\n\tgroup.Use(testMiddleware)\n\n\tgroup.HandleFunc(\"/test\", func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t})\n\n\trecorder := httptest.NewRecorder()\n\trequest, err := http.NewRequest(http.MethodGet, \"/test\", http.NoBody)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tgroup.ServeHTTP(recorder, request)\n\n\tif recorder.Code != http.StatusOK {\n\t\tt.Errorf(\"Expected status code %d, got %d\", http.StatusOK, recorder.Code)\n\t}\n\tif header := recorder.Header().Get(\"X-Test-Middleware\"); header != \"true\" {\n\t\tt.Errorf(\"Expected header X-Test-Middleware to be 'true', got '%s'\", header)\n\t}\n}\n\nfunc TestMountedBundleServeHTTP(t *testing.T) {\n\t// test ServeHTTP when called directly on a mounted bundle\n\troot := routegroup.New(http.NewServeMux())\n\troot.Use(func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tw.Header().Set(\"X-Root-Middleware\", \"true\")\n\t\t\tnext.ServeHTTP(w, r)\n\t\t})\n\t})\n\n\tmounted := root.Mount(\"/api\")\n\tmounted.HandleFunc(\"/test\", func(w http.ResponseWriter, _ *http.Request) {\n\t\t_, _ = w.Write([]byte(\"mounted handler\"))\n\t})\n\n\t// serve directly from the mounted bundle (not typical usage but should work)\n\trecorder := httptest.NewRecorder()\n\trequest, _ := http.NewRequest(http.MethodGet, \"/api/test\", http.NoBody)\n\tmounted.ServeHTTP(recorder, request)\n\n\tif recorder.Code != http.StatusOK {\n\t\tt.Errorf(\"Expected status code %d, got %d\", http.StatusOK, recorder.Code)\n\t}\n\tif body := recorder.Body.String(); body != \"mounted handler\" {\n\t\tt.Errorf(\"Expected body 'mounted handler', got '%s'\", body)\n\t}\n\t// should still apply root middleware when serving from mounted\n\tif header := recorder.Header().Get(\"X-Root-Middleware\"); header != \"true\" {\n\t\tt.Errorf(\"Expected X-Root-Middleware header to be 'true', got '%s'\", header)\n\t}\n}\n\nfunc TestGroupHandle(t *testing.T) {\n\tgroup := routegroup.New(http.NewServeMux())\n\n\tgroup.HandleFunc(\"/test\", func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t\t_, _ = w.Write([]byte(\"test handler\"))\n\t})\n\tgroup.Handle(\"GET /test2\", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t\t_, _ = w.Write([]byte(\"test2 handler\"))\n\t}))\n\n\tt.Run(\"handler function\", func(t *testing.T) {\n\t\trecorder := httptest.NewRecorder()\n\t\trequest, err := http.NewRequest(http.MethodGet, \"/test\", http.NoBody)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tgroup.ServeHTTP(recorder, request)\n\t\tif recorder.Code != http.StatusOK {\n\t\t\tt.Errorf(\"Expected status code %d, got %d\", http.StatusOK, recorder.Code)\n\t\t}\n\t\tif body := recorder.Body.String(); body != \"test handler\" {\n\t\t\tt.Errorf(\"Expected body 'test handler', got '%s'\", body)\n\t\t}\n\t})\n\n\tt.Run(\"handle, wrong method -> 405\", func(t *testing.T) {\n\t\trecorder := httptest.NewRecorder()\n\t\trequest, err := http.NewRequest(http.MethodPost, \"/test2\", http.NoBody)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tgroup.ServeHTTP(recorder, request)\n\t\tif recorder.Code != http.StatusMethodNotAllowed {\n\t\t\tt.Errorf(\"Expected status code %d, got %d\", http.StatusMethodNotAllowed, recorder.Code)\n\t\t}\n\t\tif allow := recorder.Header().Get(\"Allow\"); !strings.Contains(allow, http.MethodGet) {\n\t\t\tt.Errorf(\"expected Allow header to contain GET, got %q\", allow)\n\t\t}\n\t})\n\n\tt.Run(\"handler\", func(t *testing.T) {\n\t\trecorder := httptest.NewRecorder()\n\t\trequest, err := http.NewRequest(http.MethodGet, \"/test2\", http.NoBody)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tgroup.ServeHTTP(recorder, request)\n\t\tif recorder.Code != http.StatusOK {\n\t\t\tt.Errorf(\"Expected status code %d, got %d\", http.StatusOK, recorder.Code)\n\t\t}\n\t\tif body := recorder.Body.String(); body != \"test2 handler\" {\n\t\t\tt.Errorf(\"Expected body 'test2 handler', got '%s'\", body)\n\t\t}\n\t})\n}\n\nfunc TestBundleHandler(t *testing.T) {\n\tgroup := routegroup.New(http.NewServeMux())\n\n\tgroup.HandleFunc(\"/test\", func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t})\n\n\tt.Run(\"handler returns correct pattern and handler\", func(t *testing.T) {\n\t\trequest, err := http.NewRequest(http.MethodGet, \"/test\", http.NoBody)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\thandler, pattern := group.Handler(request)\n\n\t\tif handler == nil {\n\t\t\tt.Error(\"Expected handler to be not nil\")\n\t\t}\n\t\tif pattern != \"/test\" {\n\t\t\tt.Errorf(\"Expected pattern '/test', got '%s'\", pattern)\n\t\t}\n\t})\n\n\tt.Run(\"handler returns not-nil and empty pattern for non-existing route\", func(t *testing.T) {\n\t\trequest, err := http.NewRequest(http.MethodGet, \"/non-existing\", http.NoBody)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\thandler, pattern := group.Handler(request)\n\n\t\tif handler == nil {\n\t\t\tt.Error(\"Expected handler to be not nil\")\n\t\t}\n\t\tif pattern != \"\" {\n\t\t\tt.Errorf(\"Expected empty pattern, got '%s'\", pattern)\n\t\t}\n\t})\n}\n\nfunc TestGroupRoute(t *testing.T) {\n\tgroup := routegroup.New(http.NewServeMux())\n\n\tgroup.Route(func(g *routegroup.Bundle) {\n\t\tg.Use(testMiddleware)\n\t\tg.HandleFunc(\"/test\", func(w http.ResponseWriter, _ *http.Request) {\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t})\n\t\tg.HandleFunc(\"POST /test2\", func(w http.ResponseWriter, _ *http.Request) {\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t})\n\t})\n\n\tt.Run(\"GET /test\", func(t *testing.T) {\n\t\trecorder := httptest.NewRecorder()\n\t\trequest, err := http.NewRequest(http.MethodGet, \"/test\", http.NoBody)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tgroup.ServeHTTP(recorder, request)\n\n\t\tif recorder.Code != http.StatusOK {\n\t\t\tt.Errorf(\"Expected status code %d, got %d\", http.StatusOK, recorder.Code)\n\t\t}\n\t\tif header := recorder.Header().Get(\"X-Test-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-Test-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t})\n\n\tt.Run(\"POST /test2\", func(t *testing.T) {\n\t\trecorder := httptest.NewRecorder()\n\t\trequest, err := http.NewRequest(http.MethodPost, \"/test2\", http.NoBody)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tgroup.ServeHTTP(recorder, request)\n\n\t\tif recorder.Code != http.StatusOK {\n\t\t\tt.Errorf(\"Expected status code %d, got %d\", http.StatusOK, recorder.Code)\n\t\t}\n\t\tif header := recorder.Header().Get(\"X-Test-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-Test-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t})\n\n\tt.Run(\"GET /test2 wrong method -> 405\", func(t *testing.T) {\n\t\trecorder := httptest.NewRecorder()\n\t\trequest, err := http.NewRequest(http.MethodGet, \"/test2\", http.NoBody)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tgroup.ServeHTTP(recorder, request)\n\t\tif recorder.Code != http.StatusMethodNotAllowed {\n\t\t\tt.Errorf(\"Expected status code %d, got %d\", http.StatusMethodNotAllowed, recorder.Code)\n\t\t}\n\t\tif allow := recorder.Header().Get(\"Allow\"); allow != http.MethodPost {\n\t\t\tt.Errorf(\"expected Allow header to be POST, got %q\", allow)\n\t\t}\n\t\t// with auto-wrapping, middleware is in a sub-group and doesn't apply to 405s\n\t\tif header := recorder.Header().Get(\"X-Test-Middleware\"); header != \"\" {\n\t\t\tt.Errorf(\"Expected header X-Test-Middleware to be empty for 405 (group middleware), got '%s'\", header)\n\t\t}\n\t})\n}\n\nfunc TestGroupRouteAutoWrapping(t *testing.T) {\n\t// test that calling Route on root bundle auto-creates a group\n\trouter := routegroup.New(http.NewServeMux())\n\n\t// add middleware to router first\n\trouter.Use(func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tw.Header().Set(\"X-Root-Middleware\", \"true\")\n\t\t\tnext.ServeHTTP(w, r)\n\t\t})\n\t})\n\n\t// calling Route on root should auto-create a group\n\trouter.Route(func(g *routegroup.Bundle) {\n\t\tg.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tw.Header().Set(\"X-Group-Middleware\", \"true\")\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t})\n\t\tg.HandleFunc(\"/grouped\", func(w http.ResponseWriter, _ *http.Request) {\n\t\t\t_, _ = w.Write([]byte(\"grouped handler\"))\n\t\t})\n\t})\n\n\t// add another route directly to root\n\trouter.HandleFunc(\"/root\", func(w http.ResponseWriter, _ *http.Request) {\n\t\t_, _ = w.Write([]byte(\"root handler\"))\n\t})\n\n\tt.Run(\"grouped route has both middlewares\", func(t *testing.T) {\n\t\trecorder := httptest.NewRecorder()\n\t\trequest, err := http.NewRequest(http.MethodGet, \"/grouped\", http.NoBody)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\trouter.ServeHTTP(recorder, request)\n\n\t\tif recorder.Code != http.StatusOK {\n\t\t\tt.Errorf(\"Expected status code %d, got %d\", http.StatusOK, recorder.Code)\n\t\t}\n\t\tif body := recorder.Body.String(); body != \"grouped handler\" {\n\t\t\tt.Errorf(\"Expected body 'grouped handler', got '%s'\", body)\n\t\t}\n\t\tif header := recorder.Header().Get(\"X-Root-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected X-Root-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t\tif header := recorder.Header().Get(\"X-Group-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected X-Group-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t})\n\n\tt.Run(\"root route only has root middleware\", func(t *testing.T) {\n\t\trecorder := httptest.NewRecorder()\n\t\trequest, err := http.NewRequest(http.MethodGet, \"/root\", http.NoBody)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\trouter.ServeHTTP(recorder, request)\n\n\t\tif recorder.Code != http.StatusOK {\n\t\t\tt.Errorf(\"Expected status code %d, got %d\", http.StatusOK, recorder.Code)\n\t\t}\n\t\tif body := recorder.Body.String(); body != \"root handler\" {\n\t\t\tt.Errorf(\"Expected body 'root handler', got '%s'\", body)\n\t\t}\n\t\tif header := recorder.Header().Get(\"X-Root-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected X-Root-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t\tif header := recorder.Header().Get(\"X-Group-Middleware\"); header != \"\" {\n\t\t\tt.Errorf(\"Expected X-Group-Middleware to be empty, got '%s'\", header)\n\t\t}\n\t})\n}\n\nfunc TestGroupWithMiddleware(t *testing.T) {\n\tgroup := routegroup.New(http.NewServeMux())\n\n\tgroup.Use(func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tw.Header().Add(\"X-Original-Middleware\", \"true\")\n\t\t\tnext.ServeHTTP(w, r)\n\t\t})\n\t})\n\n\tnewGroup := group.With(func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tw.Header().Add(\"X-New-Middleware\", \"true\")\n\t\t\tnext.ServeHTTP(w, r)\n\t\t})\n\t})\n\n\tnewGroup.HandleFunc(\"/with-test\", func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t})\n\n\tnewGroup.HandleFunc(\"POST /with-test-post-only\", func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t})\n\n\tt.Run(\"GET /with-test\", func(t *testing.T) {\n\t\trecorder := httptest.NewRecorder()\n\t\trequest, err := http.NewRequest(http.MethodGet, \"/with-test\", http.NoBody)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tgroup.ServeHTTP(recorder, request)\n\n\t\tif recorder.Code != http.StatusOK {\n\t\t\tt.Errorf(\"Expected status code %d, got %d\", http.StatusOK, recorder.Code)\n\t\t}\n\t\tif header := recorder.Header().Get(\"X-Original-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-Original-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t\tif header := recorder.Header().Get(\"X-New-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-New-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t})\n\n\tt.Run(\"POST /with-test\", func(t *testing.T) {\n\t\trecorder := httptest.NewRecorder()\n\t\trequest, err := http.NewRequest(http.MethodPost, \"/with-test\", http.NoBody)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tgroup.ServeHTTP(recorder, request)\n\n\t\tif recorder.Code != http.StatusOK {\n\t\t\tt.Errorf(\"Expected status code %d, got %d\", http.StatusOK, recorder.Code)\n\t\t}\n\t\tif header := recorder.Header().Get(\"X-Original-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-Original-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t\tif header := recorder.Header().Get(\"X-New-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-New-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t})\n\n\tt.Run(\"GET /not-found\", func(t *testing.T) {\n\t\trecorder := httptest.NewRecorder()\n\t\trequest, err := http.NewRequest(http.MethodGet, \"/not-found\", http.NoBody)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tgroup.ServeHTTP(recorder, request)\n\n\t\tif recorder.Code != http.StatusNotFound {\n\t\t\tt.Errorf(\"Expected status code %d, got %d\", http.StatusNotFound, recorder.Code)\n\t\t}\n\t\tif header := recorder.Header().Get(\"X-Original-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-Original-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t\tif header := recorder.Header().Get(\"X-New-Middleware\"); header != \"\" {\n\t\t\tt.Errorf(\"Expected header X-New-Middleware to be not set, got '%s'\", header)\n\t\t}\n\t})\n\n\tt.Run(\"POST /with-test-post-only\", func(t *testing.T) {\n\t\trecorder := httptest.NewRecorder()\n\t\trequest, err := http.NewRequest(http.MethodPost, \"/with-test-post-only\", http.NoBody)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tgroup.ServeHTTP(recorder, request)\n\n\t\tif recorder.Code != http.StatusOK {\n\t\t\tt.Errorf(\"Expected status code %d, got %d\", http.StatusOK, recorder.Code)\n\t\t}\n\t\tif header := recorder.Header().Get(\"X-Original-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-Original-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t\tif header := recorder.Header().Get(\"X-New-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-New-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t})\n\n\tt.Run(\"GET /with-test-post-only wrong method -> 405\", func(t *testing.T) {\n\t\trecorder := httptest.NewRecorder()\n\t\trequest, err := http.NewRequest(http.MethodGet, \"/with-test-post-only\", http.NoBody)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tgroup.ServeHTTP(recorder, request)\n\n\t\tif recorder.Code != http.StatusMethodNotAllowed {\n\t\t\tt.Errorf(\"Expected status code %d, got %d\", http.StatusMethodNotAllowed, recorder.Code)\n\t\t}\n\t\tif allow := recorder.Header().Get(\"Allow\"); allow != http.MethodPost {\n\t\t\tt.Errorf(\"expected Allow header to be POST, got %q\", allow)\n\t\t}\n\t\tif header := recorder.Header().Get(\"X-Original-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-Original-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t\tif header := recorder.Header().Get(\"X-New-Middleware\"); header != \"\" {\n\t\t\tt.Errorf(\"Expected header X-New-Middleware to be not set, got '%s'\", header)\n\t\t}\n\t})\n}\n\nfunc TestGroupWithMiddlewareAndTopLevelAfter(t *testing.T) {\n\tgroup := routegroup.New(http.NewServeMux())\n\n\t// create subgroup and register route\n\tsub := group.Group()\n\tsub.Use(testMiddleware)\n\tsub.HandleFunc(\"/test\", func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t\t_, _ = w.Write([]byte(\"test handler\"))\n\t})\n\n\t// calling Use on the same subgroup after routes should panic\n\tdefer func() {\n\t\tif r := recover(); r == nil {\n\t\t\tt.Fatalf(\"expected panic on Use after routes registration on the same bundle\")\n\t\t}\n\t}()\n\n\tsub.Use(func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tw.Header().Add(\"X-Top-Middleware\", \"true\")\n\t\t\tnext.ServeHTTP(w, r)\n\t\t})\n\t})\n}\n\n// Test that calling Use after routes are registered on the same bundle panics,\n// and that calling Use on a parent after child routes is allowed.\n\nfunc TestUseAfterRoutesPanicsAndParentAllowed(t *testing.T) {\n\tt.Run(\"root: Use after route panics\", func(t *testing.T) {\n\t\trouter := routegroup.New(http.NewServeMux())\n\t\trouter.HandleFunc(\"/r\", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })\n\t\tdefer func() {\n\t\t\tif r := recover(); r == nil {\n\t\t\t\tt.Fatalf(\"expected panic on root.Use after routes registration on the same bundle\")\n\t\t\t}\n\t\t}()\n\t\trouter.Use(testMiddleware)\n\t})\n\n\tt.Run(\"root: Use after Route() with auto-wrap panics\", func(t *testing.T) {\n\t\trouter := routegroup.New(http.NewServeMux())\n\t\trouter.Route(func(b *routegroup.Bundle) {\n\t\t\tb.HandleFunc(\"/test\", func(w http.ResponseWriter, _ *http.Request) {})\n\t\t})\n\t\tdefer func() {\n\t\t\tif r := recover(); r == nil {\n\t\t\t\tt.Fatalf(\"expected panic on root.Use after Route() registered routes\")\n\t\t\t}\n\t\t}()\n\t\trouter.Use(testMiddleware)\n\t})\n\n\tt.Run(\"parent: Use after child routes is allowed\", func(t *testing.T) {\n\t\trouter := routegroup.New(http.NewServeMux())\n\t\tchild := router.Group()\n\t\tchild.HandleFunc(\"/child\", func(w http.ResponseWriter, _ *http.Request) {\n\t\t\t_, _ = w.Write([]byte(\"ok\"))\n\t\t})\n\n\t\t// parent hasn't registered any routes yet; calling Use should not panic\n\t\trouter.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tw.Header().Set(\"X-Parent\", \"true\")\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t})\n\n\t\trec := httptest.NewRecorder()\n\t\treq := httptest.NewRequest(http.MethodGet, \"/child\", http.NoBody)\n\t\trouter.ServeHTTP(rec, req)\n\t\tif rec.Code != http.StatusOK {\n\t\t\tt.Fatalf(\"unexpected status %d\", rec.Code)\n\t\t}\n\t\tif hv := rec.Header().Get(\"X-Parent\"); hv != \"true\" {\n\t\t\tt.Fatalf(\"expected global parent middleware to apply, got %q\", hv)\n\t\t}\n\t})\n}\n\n// DisableNotFoundHandler semantics are removed; global middlewares always apply.\n\nfunc TestGroupWithMoreMiddleware(t *testing.T) {\n\tgroup := routegroup.New(http.NewServeMux())\n\n\tnewGroup := group.With(\n\t\tfunc(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tw.Header().Add(\"X-New-Middleware\", \"true\")\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t},\n\t\tfunc(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tw.Header().Add(\"X-More-Middleware\", \"true\")\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t},\n\t)\n\n\tnewGroup.HandleFunc(\"/with-test\", func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t})\n\n\trecorder := httptest.NewRecorder()\n\trequest, err := http.NewRequest(http.MethodGet, \"/with-test\", http.NoBody)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tgroup.ServeHTTP(recorder, request)\n\n\tif recorder.Code != http.StatusOK {\n\t\tt.Errorf(\"Expected status code %d, got %d\", http.StatusOK, recorder.Code)\n\t}\n\tif header := recorder.Header().Get(\"X-New-Middleware\"); header != \"true\" {\n\t\tt.Errorf(\"Expected header X-New-Middleware to be 'true', got '%s'\", header)\n\t}\n\tif header := recorder.Header().Get(\"X-More-Middleware\"); header != \"true\" {\n\t\tt.Errorf(\"Expected header X-More-Middleware to be 'true', got '%s'\", header)\n\t}\n}\n\nfunc TestMiddlewareOrder(t *testing.T) {\n\tvar order []string\n\n\tmkMiddleware := func(name string) func(http.Handler) http.Handler {\n\t\treturn func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\torder = append(order, \"before \"+name)\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\torder = append(order, \"after \"+name)\n\t\t\t})\n\t\t}\n\t}\n\n\tgroup := routegroup.New(http.NewServeMux())\n\tgroup.Use(mkMiddleware(\"root\"))\n\n\tapi := group.Mount(\"/api\")\n\tapi.Use(mkMiddleware(\"api\"))\n\n\tusers := api.With(mkMiddleware(\"users\"))\n\tusers.HandleFunc(\"/action\", func(w http.ResponseWriter, _ *http.Request) {\n\t\torder = append(order, \"handler\")\n\t\t_, _ = w.Write([]byte(\"ok\"))\n\t})\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodGet, \"/api/action\", http.NoBody)\n\tgroup.ServeHTTP(rec, req)\n\n\texpected := []string{\n\t\t\"before root\",\n\t\t\"before api\",\n\t\t\"before users\",\n\t\t\"handler\",\n\t\t\"after users\",\n\t\t\"after api\",\n\t\t\"after root\",\n\t}\n\n\tif !reflect.DeepEqual(order, expected) {\n\t\tt.Errorf(\"wrong middleware execution order\\nwant: %v\\ngot:  %v\", expected, order)\n\t}\n}\n\nfunc TestConcurrentRequests(t *testing.T) {\n\tgroup := routegroup.New(http.NewServeMux())\n\tgroup.HandleFunc(\"/concurrent\", func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t})\n\n\tvar wg sync.WaitGroup\n\tfor i := 0; i < 10; i++ {\n\t\twg.Add(1)\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\trec := httptest.NewRecorder()\n\t\t\treq, _ := http.NewRequest(http.MethodGet, \"/concurrent\", http.NoBody)\n\t\t\tgroup.ServeHTTP(rec, req)\n\t\t\tif rec.Code != http.StatusOK {\n\t\t\t\tt.Errorf(\"got %d, want %d\", rec.Code, http.StatusOK)\n\t\t\t}\n\t\t}()\n\t}\n\twg.Wait()\n}\n\nfunc TestHTTPServerWrap(t *testing.T) {\n\tmw1 := func(h http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tw.Header().Set(\"X-MW1\", \"1\")\n\t\t\th.ServeHTTP(w, r)\n\t\t})\n\t}\n\tmw2 := func(h http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tw.Header().Set(\"X-MW2\", \"2\")\n\t\t\th.ServeHTTP(w, r)\n\t\t})\n\t}\n\n\thandlers := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t\t_, _ = w.Write([]byte(\"test handler\"))\n\t})\n\n\tts := httptest.NewServer(routegroup.Wrap(handlers, mw1, mw2))\n\tdefer ts.Close()\n\n\tresp, err := http.Get(ts.URL)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tt.Errorf(\"Expected status code %d, got %d\", http.StatusOK, resp.StatusCode)\n\t}\n\tif header := resp.Header.Get(\"X-MW1\"); header != \"1\" {\n\t\tt.Errorf(\"Expected header X-MW1 to be '1', got '%s'\", header)\n\t}\n\tif header := resp.Header.Get(\"X-MW2\"); header != \"2\" {\n\t\tt.Errorf(\"Expected header X-MW2 to be '2', got '%s'\", header)\n\t}\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif string(body) != \"test handler\" {\n\t\tt.Errorf(\"Expected body 'test handler', got '%s'\", string(body))\n\t}\n}\n"
  },
  {
    "path": "middleware_test.go",
    "content": "package routegroup_test\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"sync\"\n\t\"testing\"\n\n\t\"github.com/go-pkgz/routegroup\"\n)\n\nfunc TestMiddlewareCanAccessPathValues(t *testing.T) {\n\t// test path value accessibility in middlewares\n\t// EXPECTED: root/global middlewares can't see PathValue (runs before routing)\n\t// EXPECTED: mounted group middlewares CAN see PathValue (applied at registration)\n\ttests := []struct {\n\t\tname         string\n\t\tsetupFunc    func() *routegroup.Bundle\n\t\trequestPath  string\n\t\texpectedID   string\n\t\texpectedUser string\n\t}{\n\t\t{\n\t\t\tname: \"root middleware cannot access path params (expected)\",\n\t\t\tsetupFunc: func() *routegroup.Bundle {\n\t\t\t\trtr := routegroup.New(http.NewServeMux())\n\n\t\t\t\t// root middleware runs BEFORE mux.ServeHTTP sets path values\n\t\t\t\trtr.Use(func(next http.Handler) http.Handler {\n\t\t\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t\t\t// PathValue is empty here - this is EXPECTED\n\t\t\t\t\t\tid := r.PathValue(\"id\")\n\t\t\t\t\t\tw.Header().Set(\"X-Root-Middleware-ID\", id) // will be empty\n\n\t\t\t\t\t\t// but Pattern IS available (our fix from #24)\n\t\t\t\t\t\tw.Header().Set(\"X-Root-Pattern\", r.Pattern)\n\t\t\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\t\t})\n\t\t\t\t})\n\n\t\t\t\trtr.HandleFunc(\"GET /users/{id}\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t\t// handler CAN access path values\n\t\t\t\t\tw.Header().Set(\"X-Handler-ID\", r.PathValue(\"id\"))\n\t\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\t})\n\n\t\t\t\treturn rtr\n\t\t\t},\n\t\t\trequestPath: \"/users/123\",\n\t\t\texpectedID:  \"\", // empty in root middleware is EXPECTED\n\t\t},\n\t\t{\n\t\t\tname: \"mounted group with path params\",\n\t\t\tsetupFunc: func() *routegroup.Bundle {\n\t\t\t\trtr := routegroup.New(http.NewServeMux())\n\n\t\t\t\tapi := rtr.Mount(\"/api\")\n\t\t\t\tapi.Use(func(next http.Handler) http.Handler {\n\t\t\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t\t\t// path values should be accessible in mounted group middleware\n\t\t\t\t\t\tid := r.PathValue(\"id\")\n\t\t\t\t\t\tuser := r.PathValue(\"user\")\n\t\t\t\t\t\tif id != \"\" {\n\t\t\t\t\t\t\tw.Header().Set(\"X-Middleware-ID\", id)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif user != \"\" {\n\t\t\t\t\t\t\tw.Header().Set(\"X-Middleware-User\", user)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\t\t})\n\t\t\t\t})\n\n\t\t\t\tapi.HandleFunc(\"GET /users/{user}/posts/{id}\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\t})\n\n\t\t\t\treturn rtr\n\t\t\t},\n\t\t\trequestPath:  \"/api/users/john/posts/456\",\n\t\t\texpectedID:   \"456\",\n\t\t\texpectedUser: \"john\",\n\t\t},\n\t\t{\n\t\t\tname: \"nested mounted groups with params\",\n\t\t\tsetupFunc: func() *routegroup.Bundle {\n\t\t\t\trtr := routegroup.New(http.NewServeMux())\n\n\t\t\t\tv1 := rtr.Mount(\"/v1\")\n\t\t\t\tusers := v1.Mount(\"/users\")\n\n\t\t\t\tusers.Use(func(next http.Handler) http.Handler {\n\t\t\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t\t\tid := r.PathValue(\"id\")\n\t\t\t\t\t\taction := r.PathValue(\"action\")\n\t\t\t\t\t\tif id != \"\" {\n\t\t\t\t\t\t\tw.Header().Set(\"X-Middleware-ID\", id)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif action != \"\" {\n\t\t\t\t\t\t\tw.Header().Set(\"X-Middleware-Action\", action)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\t\t})\n\t\t\t\t})\n\n\t\t\t\tusers.HandleFunc(\"GET /{id}/{action}\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\t})\n\n\t\t\t\treturn rtr\n\t\t\t},\n\t\t\trequestPath: \"/v1/users/789/edit\",\n\t\t\texpectedID:  \"789\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tbundle := tt.setupFunc()\n\n\t\t\treq, err := http.NewRequest(http.MethodGet, tt.requestPath, http.NoBody)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\n\t\t\trec := httptest.NewRecorder()\n\t\t\tbundle.ServeHTTP(rec, req)\n\n\t\t\tif rec.Code != http.StatusOK {\n\t\t\t\tt.Errorf(\"expected status 200, got %d\", rec.Code)\n\t\t\t}\n\n\t\t\t// verify path value accessibility based on middleware type\n\t\t\tif tt.name == \"root middleware cannot access path params (expected)\" {\n\t\t\t\t// verify root middleware can't see path values\n\t\t\t\tif got := rec.Header().Get(\"X-Root-Middleware-ID\"); got != \"\" {\n\t\t\t\t\tt.Errorf(\"root middleware should not see path values, got %q\", got)\n\t\t\t\t}\n\t\t\t\t// but handler can\n\t\t\t\tif got := rec.Header().Get(\"X-Handler-ID\"); got != \"123\" {\n\t\t\t\t\tt.Errorf(\"handler should see path value, got %q\", got)\n\t\t\t\t}\n\t\t\t\t// and Pattern is available (from our fix)\n\t\t\t\tif got := rec.Header().Get(\"X-Root-Pattern\"); got != \"GET /users/{id}\" {\n\t\t\t\t\tt.Errorf(\"root middleware should see pattern, got %q\", got)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// mounted group middlewares CAN see path values\n\t\t\t\tif tt.expectedID != \"\" {\n\t\t\t\t\tif got := rec.Header().Get(\"X-Middleware-ID\"); got != tt.expectedID {\n\t\t\t\t\t\tt.Errorf(\"middleware ID = %q, want %q\", got, tt.expectedID)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif tt.expectedUser != \"\" {\n\t\t\t\t\tif got := rec.Header().Get(\"X-Middleware-User\"); got != tt.expectedUser {\n\t\t\t\t\t\tt.Errorf(\"middleware User = %q, want %q\", got, tt.expectedUser)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestMiddlewareAbortChain(t *testing.T) {\n\t// test that middleware can stop the chain by not calling next.ServeHTTP()\n\t// this is critical for auth/security middleware\n\n\tt.Run(\"auth middleware aborts on unauthorized\", func(t *testing.T) {\n\t\thandlerCalled := false\n\t\tmiddleware2Called := false\n\n\t\trtr := routegroup.New(http.NewServeMux())\n\n\t\t// first middleware - auth check that aborts\n\t\trtr.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tw.Header().Set(\"X-Auth-Checked\", \"true\")\n\n\t\t\t\tif r.Header.Get(\"Authorization\") == \"\" {\n\t\t\t\t\t// abort chain - don't call next\n\t\t\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\t\t\t_, _ = w.Write([]byte(\"unauthorized\"))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t})\n\n\t\t// second middleware - should not be called if first aborts\n\t\trtr.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tmiddleware2Called = true\n\t\t\t\tw.Header().Set(\"X-Middleware2\", \"called\")\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t})\n\n\t\trtr.HandleFunc(\"GET /protected\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\thandlerCalled = true\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t_, _ = w.Write([]byte(\"protected content\"))\n\t\t})\n\n\t\t// test unauthorized request\n\t\treq, _ := http.NewRequest(http.MethodGet, \"/protected\", http.NoBody)\n\t\trec := httptest.NewRecorder()\n\n\t\trtr.ServeHTTP(rec, req)\n\n\t\tif rec.Code != http.StatusUnauthorized {\n\t\t\tt.Errorf(\"expected 401, got %d\", rec.Code)\n\t\t}\n\t\tif rec.Body.String() != \"unauthorized\" {\n\t\t\tt.Errorf(\"expected 'unauthorized', got %q\", rec.Body.String())\n\t\t}\n\t\tif handlerCalled {\n\t\t\tt.Error(\"handler should not be called when middleware aborts\")\n\t\t}\n\t\tif middleware2Called {\n\t\t\tt.Error(\"second middleware should not be called when first aborts\")\n\t\t}\n\t\tif rec.Header().Get(\"X-Auth-Checked\") != \"true\" {\n\t\t\tt.Error(\"first middleware should have run\")\n\t\t}\n\t\tif rec.Header().Get(\"X-Middleware2\") != \"\" {\n\t\t\tt.Error(\"second middleware should not have set header\")\n\t\t}\n\t})\n\n\tt.Run(\"middleware abort with mounted groups\", func(t *testing.T) {\n\t\thandlerCalled := false\n\n\t\trtr := routegroup.New(http.NewServeMux())\n\n\t\t// root middleware - always passes\n\t\trtr.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tw.Header().Set(\"X-Root\", \"passed\")\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t})\n\n\t\tapi := rtr.Mount(\"/api\")\n\n\t\t// api middleware - aborts on missing API key\n\t\tapi.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tw.Header().Set(\"X-API-Check\", \"true\")\n\n\t\t\t\tif r.Header.Get(\"X-API-Key\") == \"\" {\n\t\t\t\t\tw.WriteHeader(http.StatusForbidden)\n\t\t\t\t\t_, _ = w.Write([]byte(\"API key required\"))\n\t\t\t\t\treturn // abort\n\t\t\t\t}\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t})\n\n\t\tapi.HandleFunc(\"GET /data\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\thandlerCalled = true\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t_, _ = w.Write([]byte(\"data\"))\n\t\t})\n\n\t\t// test without API key\n\t\treq, _ := http.NewRequest(http.MethodGet, \"/api/data\", http.NoBody)\n\t\trec := httptest.NewRecorder()\n\n\t\trtr.ServeHTTP(rec, req)\n\n\t\tif rec.Code != http.StatusForbidden {\n\t\t\tt.Errorf(\"expected 403, got %d\", rec.Code)\n\t\t}\n\t\tif rec.Body.String() != \"API key required\" {\n\t\t\tt.Errorf(\"expected 'API key required', got %q\", rec.Body.String())\n\t\t}\n\t\tif handlerCalled {\n\t\t\tt.Error(\"handler should not be called when middleware aborts\")\n\t\t}\n\t\t// root middleware should have run\n\t\tif rec.Header().Get(\"X-Root\") != \"passed\" {\n\t\t\tt.Error(\"root middleware should have run\")\n\t\t}\n\t\t// api middleware should have run and aborted\n\t\tif rec.Header().Get(\"X-API-Check\") != \"true\" {\n\t\t\tt.Error(\"API middleware should have run\")\n\t\t}\n\t})\n\n\tt.Run(\"middleware chain continues with authorization\", func(t *testing.T) {\n\t\thandlerCalled := false\n\n\t\trtr := routegroup.New(http.NewServeMux())\n\n\t\t// auth middleware that passes with correct header\n\t\trtr.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tw.Header().Set(\"X-Auth-Checked\", \"true\")\n\n\t\t\t\tif r.Header.Get(\"Authorization\") == \"\" {\n\t\t\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t})\n\n\t\t// second middleware\n\t\trtr.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tw.Header().Set(\"X-Middleware2\", \"called\")\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t})\n\n\t\trtr.HandleFunc(\"GET /protected\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\thandlerCalled = true\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t_, _ = w.Write([]byte(\"protected content\"))\n\t\t})\n\n\t\t// test authorized request\n\t\treq, _ := http.NewRequest(http.MethodGet, \"/protected\", http.NoBody)\n\t\treq.Header.Set(\"Authorization\", \"Bearer token\")\n\t\trec := httptest.NewRecorder()\n\n\t\trtr.ServeHTTP(rec, req)\n\n\t\tif rec.Code != http.StatusOK {\n\t\t\tt.Errorf(\"expected 200, got %d\", rec.Code)\n\t\t}\n\t\tif rec.Body.String() != \"protected content\" {\n\t\t\tt.Errorf(\"expected 'protected content', got %q\", rec.Body.String())\n\t\t}\n\t\tif !handlerCalled {\n\t\t\tt.Error(\"handler should be called with authorization\")\n\t\t}\n\t\tif rec.Header().Get(\"X-Auth-Checked\") != \"true\" {\n\t\t\tt.Error(\"auth middleware should have run\")\n\t\t}\n\t\tif rec.Header().Get(\"X-Middleware2\") != \"called\" {\n\t\t\tt.Error(\"second middleware should have run\")\n\t\t}\n\t})\n}\n\nfunc TestWithMethodMiddlewareCounting(t *testing.T) {\n\t// test that With() properly tracks middleware count to avoid double execution\n\t// this is critical to prevent issues like #24\n\n\tt.Run(\"With() creates new bundle with correct middleware count\", func(t *testing.T) {\n\t\tcallCounts := make(map[string]int)\n\n\t\trtr := routegroup.New(http.NewServeMux())\n\n\t\t// root middleware\n\t\trtr.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tcallCounts[\"root\"]++\n\t\t\t\tw.Header().Set(\"X-MW-Order\", \"root\")\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t})\n\n\t\t// create new bundle with additional middleware using With()\n\t\twithBundle := rtr.With(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tcallCounts[\"with\"]++\n\t\t\t\texisting := w.Header().Get(\"X-MW-Order\")\n\t\t\t\tw.Header().Set(\"X-MW-Order\", existing+\",with\")\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t})\n\n\t\t// register route on the With bundle\n\t\twithBundle.HandleFunc(\"GET /test\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\tcallCounts[\"handler\"]++\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t})\n\n\t\treq, _ := http.NewRequest(http.MethodGet, \"/test\", http.NoBody)\n\t\trec := httptest.NewRecorder()\n\n\t\trtr.ServeHTTP(rec, req)\n\n\t\t// verify each middleware called exactly once\n\t\tif callCounts[\"root\"] != 1 {\n\t\t\tt.Errorf(\"root middleware called %d times, expected 1\", callCounts[\"root\"])\n\t\t}\n\t\tif callCounts[\"with\"] != 1 {\n\t\t\tt.Errorf(\"with middleware called %d times, expected 1\", callCounts[\"with\"])\n\t\t}\n\t\tif callCounts[\"handler\"] != 1 {\n\t\t\tt.Errorf(\"handler called %d times, expected 1\", callCounts[\"handler\"])\n\t\t}\n\n\t\t// verify order\n\t\tif order := rec.Header().Get(\"X-MW-Order\"); order != \"root,with\" {\n\t\t\tt.Errorf(\"middleware order = %q, expected 'root,with'\", order)\n\t\t}\n\t})\n\n\tt.Run(\"multiple With() calls maintain proper count\", func(t *testing.T) {\n\t\tcallCounts := make(map[string]int)\n\n\t\trtr := routegroup.New(http.NewServeMux())\n\n\t\trtr.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tcallCounts[\"root\"]++\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t})\n\n\t\t// first With()\n\t\tbundle1 := rtr.With(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tcallCounts[\"with1\"]++\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t})\n\n\t\t// second With() on top of first\n\t\tbundle2 := bundle1.With(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tcallCounts[\"with2\"]++\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t})\n\n\t\tbundle2.HandleFunc(\"GET /nested\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\tcallCounts[\"handler\"]++\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t})\n\n\t\treq, _ := http.NewRequest(http.MethodGet, \"/nested\", http.NoBody)\n\t\trec := httptest.NewRecorder()\n\n\t\trtr.ServeHTTP(rec, req)\n\n\t\t// all middlewares should be called exactly once\n\t\tif callCounts[\"root\"] != 1 {\n\t\t\tt.Errorf(\"root middleware called %d times, expected 1\", callCounts[\"root\"])\n\t\t}\n\t\tif callCounts[\"with1\"] != 1 {\n\t\t\tt.Errorf(\"with1 middleware called %d times, expected 1\", callCounts[\"with1\"])\n\t\t}\n\t\tif callCounts[\"with2\"] != 1 {\n\t\t\tt.Errorf(\"with2 middleware called %d times, expected 1\", callCounts[\"with2\"])\n\t\t}\n\t\tif callCounts[\"handler\"] != 1 {\n\t\t\tt.Errorf(\"handler called %d times, expected 1\", callCounts[\"handler\"])\n\t\t}\n\t})\n\n\tt.Run(\"With() on mounted group maintains correct count\", func(t *testing.T) {\n\t\tcallCounts := make(map[string]int)\n\n\t\trtr := routegroup.New(http.NewServeMux())\n\n\t\t// root middleware\n\t\trtr.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tcallCounts[\"root\"]++\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t})\n\n\t\t// mount a group\n\t\tapi := rtr.Mount(\"/api\")\n\n\t\t// add middleware to mounted group\n\t\tapi.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tcallCounts[\"api\"]++\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t})\n\n\t\t// use With() on mounted group\n\t\tapiWith := api.With(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tcallCounts[\"api-with\"]++\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t})\n\n\t\tapiWith.HandleFunc(\"GET /data\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\tcallCounts[\"handler\"]++\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t})\n\n\t\treq, _ := http.NewRequest(http.MethodGet, \"/api/data\", http.NoBody)\n\t\trec := httptest.NewRecorder()\n\n\t\trtr.ServeHTTP(rec, req)\n\n\t\t// verify no double execution\n\t\tif callCounts[\"root\"] != 1 {\n\t\t\tt.Errorf(\"root middleware called %d times, expected 1\", callCounts[\"root\"])\n\t\t}\n\t\tif callCounts[\"api\"] != 1 {\n\t\t\tt.Errorf(\"api middleware called %d times, expected 1\", callCounts[\"api\"])\n\t\t}\n\t\tif callCounts[\"api-with\"] != 1 {\n\t\t\tt.Errorf(\"api-with middleware called %d times, expected 1\", callCounts[\"api-with\"])\n\t\t}\n\t\tif callCounts[\"handler\"] != 1 {\n\t\t\tt.Errorf(\"handler called %d times, expected 1\", callCounts[\"handler\"])\n\t\t}\n\t})\n}\n\nfunc TestResponseWriterInterception(t *testing.T) {\n\t// test that middlewares can intercept and modify responses\n\t// this is critical for logging, metrics, response manipulation\n\n\tt.Run(\"middleware can capture status code\", func(t *testing.T) {\n\t\tvar capturedStatus int\n\n\t\trtr := routegroup.New(http.NewServeMux())\n\n\t\t// status capturing middleware\n\t\trtr.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t// wrap response writer to capture status\n\t\t\t\twrapped := &statusRecorder{ResponseWriter: w, status: http.StatusOK}\n\t\t\t\tnext.ServeHTTP(wrapped, r)\n\t\t\t\tcapturedStatus = wrapped.status\n\t\t\t})\n\t\t})\n\n\t\trtr.HandleFunc(\"GET /success\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\tw.WriteHeader(http.StatusCreated)\n\t\t\t_, _ = w.Write([]byte(\"created\"))\n\t\t})\n\n\t\trtr.HandleFunc(\"GET /error\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\t\t_, _ = w.Write([]byte(\"error\"))\n\t\t})\n\n\t\t// test success response\n\t\treq1, _ := http.NewRequest(http.MethodGet, \"/success\", http.NoBody)\n\t\trec1 := httptest.NewRecorder()\n\t\trtr.ServeHTTP(rec1, req1)\n\n\t\tif capturedStatus != http.StatusCreated {\n\t\t\tt.Errorf(\"captured status = %d, want %d\", capturedStatus, http.StatusCreated)\n\t\t}\n\t\tif rec1.Code != http.StatusCreated {\n\t\t\tt.Errorf(\"response status = %d, want %d\", rec1.Code, http.StatusCreated)\n\t\t}\n\n\t\t// test error response\n\t\treq2, _ := http.NewRequest(http.MethodGet, \"/error\", http.NoBody)\n\t\trec2 := httptest.NewRecorder()\n\t\trtr.ServeHTTP(rec2, req2)\n\n\t\tif capturedStatus != http.StatusInternalServerError {\n\t\t\tt.Errorf(\"captured status = %d, want %d\", capturedStatus, http.StatusInternalServerError)\n\t\t}\n\t})\n\n\tt.Run(\"middleware can modify response body\", func(t *testing.T) {\n\t\trtr := routegroup.New(http.NewServeMux())\n\n\t\t// response modifying middleware\n\t\trtr.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t// capture response in buffer\n\t\t\t\tbuf := &responseBuffer{ResponseWriter: w, buffer: []byte{}}\n\t\t\t\tnext.ServeHTTP(buf, r)\n\n\t\t\t\t// modify and write actual response\n\t\t\t\tmodified := append([]byte(\"PREFIX:\"), buf.buffer...)\n\t\t\t\t_, _ = w.Write(modified)\n\t\t\t})\n\t\t})\n\n\t\trtr.HandleFunc(\"GET /test\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\t_, _ = w.Write([]byte(\"original\"))\n\t\t})\n\n\t\treq, _ := http.NewRequest(http.MethodGet, \"/test\", http.NoBody)\n\t\trec := httptest.NewRecorder()\n\t\trtr.ServeHTTP(rec, req)\n\n\t\tif body := rec.Body.String(); body != \"PREFIX:original\" {\n\t\t\tt.Errorf(\"body = %q, want %q\", body, \"PREFIX:original\")\n\t\t}\n\t})\n\n\tt.Run(\"multiple middlewares can wrap response writer\", func(t *testing.T) {\n\t\tvar statuses []int\n\n\t\trtr := routegroup.New(http.NewServeMux())\n\n\t\t// first middleware - captures status\n\t\trtr.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\twrapped := &statusRecorder{ResponseWriter: w, status: http.StatusOK}\n\t\t\t\tnext.ServeHTTP(wrapped, r)\n\t\t\t\tstatuses = append(statuses, wrapped.status)\n\t\t\t})\n\t\t})\n\n\t\t// second middleware - also captures status\n\t\trtr.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\twrapped := &statusRecorder{ResponseWriter: w, status: http.StatusOK}\n\t\t\t\tnext.ServeHTTP(wrapped, r)\n\t\t\t\tstatuses = append(statuses, wrapped.status)\n\t\t\t})\n\t\t})\n\n\t\trtr.HandleFunc(\"GET /test\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\tw.WriteHeader(http.StatusAccepted)\n\t\t})\n\n\t\treq, _ := http.NewRequest(http.MethodGet, \"/test\", http.NoBody)\n\t\trec := httptest.NewRecorder()\n\n\t\tstatuses = nil\n\t\trtr.ServeHTTP(rec, req)\n\n\t\t// both middlewares should capture the same status\n\t\tif len(statuses) != 2 {\n\t\t\tt.Errorf(\"expected 2 status captures, got %d\", len(statuses))\n\t\t}\n\t\tif len(statuses) == 2 {\n\t\t\tif statuses[0] != http.StatusAccepted {\n\t\t\t\tt.Errorf(\"first middleware captured %d, want %d\", statuses[0], http.StatusAccepted)\n\t\t\t}\n\t\t\tif statuses[1] != http.StatusAccepted {\n\t\t\t\tt.Errorf(\"second middleware captured %d, want %d\", statuses[1], http.StatusAccepted)\n\t\t\t}\n\t\t}\n\t})\n}\n\n// statusRecorder wraps ResponseWriter to capture status code\ntype statusRecorder struct {\n\thttp.ResponseWriter\n\tstatus  int\n\twritten bool\n}\n\nfunc (r *statusRecorder) WriteHeader(status int) {\n\tif !r.written {\n\t\tr.status = status\n\t\tr.written = true\n\t}\n\tr.ResponseWriter.WriteHeader(status)\n}\n\nfunc (r *statusRecorder) Write(b []byte) (int, error) {\n\tif !r.written {\n\t\tr.written = true\n\t\t// status remains default (200) if WriteHeader wasn't called\n\t}\n\treturn r.ResponseWriter.Write(b)\n}\n\n// responseBuffer captures response body\ntype responseBuffer struct {\n\thttp.ResponseWriter\n\tbuffer []byte\n}\n\nfunc (b *responseBuffer) Write(data []byte) (int, error) {\n\tb.buffer = append(b.buffer, data...)\n\treturn len(data), nil\n}\n\nfunc TestContextPropagation(t *testing.T) {\n\t// test that context values propagate through middleware chain\n\t// this is critical for request IDs, user auth, tracing, etc.\n\n\ttype contextKey string\n\tconst (\n\t\trequestIDKey contextKey = \"requestID\"\n\t\tuserIDKey    contextKey = \"userID\"\n\t\ttraceKey     contextKey = \"trace\"\n\t)\n\n\tt.Run(\"context values propagate through chain\", func(t *testing.T) {\n\t\trtr := routegroup.New(http.NewServeMux())\n\n\t\t// first middleware - adds request ID\n\t\trtr.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tctx := context.WithValue(r.Context(), requestIDKey, \"req-123\")\n\t\t\t\tnext.ServeHTTP(w, r.WithContext(ctx))\n\t\t\t})\n\t\t})\n\n\t\t// second middleware - adds user ID and verifies request ID\n\t\trtr.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t// verify request ID from first middleware\n\t\t\t\tif reqID := r.Context().Value(requestIDKey); reqID != \"req-123\" {\n\t\t\t\t\tt.Errorf(\"middleware 2: requestID = %v, want 'req-123'\", reqID)\n\t\t\t\t}\n\n\t\t\t\tctx := context.WithValue(r.Context(), userIDKey, \"user-456\")\n\t\t\t\tnext.ServeHTTP(w, r.WithContext(ctx))\n\t\t\t})\n\t\t})\n\n\t\t// handler - verifies both values\n\t\trtr.HandleFunc(\"GET /test\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\treqID := r.Context().Value(requestIDKey)\n\t\t\tuserID := r.Context().Value(userIDKey)\n\n\t\t\tif reqID != \"req-123\" {\n\t\t\t\tt.Errorf(\"handler: requestID = %v, want 'req-123'\", reqID)\n\t\t\t}\n\t\t\tif userID != \"user-456\" {\n\t\t\t\tt.Errorf(\"handler: userID = %v, want 'user-456'\", userID)\n\t\t\t}\n\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t})\n\n\t\treq, _ := http.NewRequest(http.MethodGet, \"/test\", http.NoBody)\n\t\trec := httptest.NewRecorder()\n\t\trtr.ServeHTTP(rec, req)\n\n\t\tif rec.Code != http.StatusOK {\n\t\t\tt.Errorf(\"status = %d, want %d\", rec.Code, http.StatusOK)\n\t\t}\n\t})\n\n\tt.Run(\"context cancellation stops chain\", func(t *testing.T) {\n\t\thandlerCalled := false\n\t\tmiddleware2Called := false\n\n\t\trtr := routegroup.New(http.NewServeMux())\n\n\t\t// first middleware - cancels context\n\t\trtr.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tctx, cancel := context.WithCancel(r.Context())\n\t\t\t\tcancel() // immediately cancel\n\n\t\t\t\t// check if context is done before calling next\n\t\t\t\tselect {\n\t\t\t\tcase <-ctx.Done():\n\t\t\t\t\tw.WriteHeader(http.StatusServiceUnavailable)\n\t\t\t\t\treturn\n\t\t\t\tdefault:\n\t\t\t\t\tnext.ServeHTTP(w, r.WithContext(ctx))\n\t\t\t\t}\n\t\t\t})\n\t\t})\n\n\t\t// second middleware - should not be called\n\t\trtr.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tmiddleware2Called = true\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t})\n\n\t\trtr.HandleFunc(\"GET /test\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\thandlerCalled = true\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t})\n\n\t\treq, _ := http.NewRequest(http.MethodGet, \"/test\", http.NoBody)\n\t\trec := httptest.NewRecorder()\n\t\trtr.ServeHTTP(rec, req)\n\n\t\tif rec.Code != http.StatusServiceUnavailable {\n\t\t\tt.Errorf(\"status = %d, want %d\", rec.Code, http.StatusServiceUnavailable)\n\t\t}\n\t\tif middleware2Called {\n\t\t\tt.Error(\"middleware 2 should not be called after context cancellation\")\n\t\t}\n\t\tif handlerCalled {\n\t\t\tt.Error(\"handler should not be called after context cancellation\")\n\t\t}\n\t})\n\n\tt.Run(\"context values work with mounted groups\", func(t *testing.T) {\n\t\trtr := routegroup.New(http.NewServeMux())\n\n\t\t// root middleware - adds trace ID\n\t\trtr.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tctx := context.WithValue(r.Context(), traceKey, \"trace-root\")\n\t\t\t\tnext.ServeHTTP(w, r.WithContext(ctx))\n\t\t\t})\n\t\t})\n\n\t\tapi := rtr.Mount(\"/api\")\n\n\t\t// api middleware - adds request ID and checks trace\n\t\tapi.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t// verify trace from root\n\t\t\t\tif trace := r.Context().Value(traceKey); trace != \"trace-root\" {\n\t\t\t\t\tt.Errorf(\"api middleware: trace = %v, want 'trace-root'\", trace)\n\t\t\t\t}\n\n\t\t\t\tctx := context.WithValue(r.Context(), requestIDKey, \"req-api\")\n\t\t\t\tnext.ServeHTTP(w, r.WithContext(ctx))\n\t\t\t})\n\t\t})\n\n\t\tapi.HandleFunc(\"GET /data\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\ttrace := r.Context().Value(traceKey)\n\t\t\treqID := r.Context().Value(requestIDKey)\n\n\t\t\tif trace != \"trace-root\" {\n\t\t\t\tt.Errorf(\"handler: trace = %v, want 'trace-root'\", trace)\n\t\t\t}\n\t\t\tif reqID != \"req-api\" {\n\t\t\t\tt.Errorf(\"handler: requestID = %v, want 'req-api'\", reqID)\n\t\t\t}\n\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t})\n\n\t\treq, _ := http.NewRequest(http.MethodGet, \"/api/data\", http.NoBody)\n\t\trec := httptest.NewRecorder()\n\t\trtr.ServeHTTP(rec, req)\n\n\t\tif rec.Code != http.StatusOK {\n\t\t\tt.Errorf(\"status = %d, want %d\", rec.Code, http.StatusOK)\n\t\t}\n\t})\n}\n\nfunc TestMiddlewareModifiesRequest(t *testing.T) {\n\t// test that middlewares can modify request properties\n\t// this is critical for adding headers, modifying paths, etc.\n\n\tt.Run(\"middleware can add and modify headers\", func(t *testing.T) {\n\t\tvar capturedHeaders http.Header\n\n\t\trtr := routegroup.New(http.NewServeMux())\n\n\t\t// first middleware - adds header\n\t\trtr.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tr.Header.Set(\"X-Request-ID\", \"req-123\")\n\t\t\t\tr.Header.Set(\"X-Custom\", \"value1\")\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t})\n\n\t\t// second middleware - modifies header\n\t\trtr.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t// verify first middleware's header\n\t\t\t\tif reqID := r.Header.Get(\"X-Request-ID\"); reqID != \"req-123\" {\n\t\t\t\t\tt.Errorf(\"middleware 2: X-Request-ID = %q, want 'req-123'\", reqID)\n\t\t\t\t}\n\n\t\t\t\t// modify existing header\n\t\t\t\tr.Header.Set(\"X-Custom\", \"value2\")\n\t\t\t\tr.Header.Set(\"X-Middleware-2\", \"processed\")\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t})\n\n\t\trtr.HandleFunc(\"GET /test\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\tcapturedHeaders = r.Header.Clone()\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t})\n\n\t\treq, _ := http.NewRequest(http.MethodGet, \"/test\", http.NoBody)\n\t\treq.Header.Set(\"X-Original\", \"client-value\")\n\t\trec := httptest.NewRecorder()\n\n\t\trtr.ServeHTTP(rec, req)\n\n\t\t// verify headers in handler\n\t\tif capturedHeaders.Get(\"X-Original\") != \"client-value\" {\n\t\t\tt.Errorf(\"X-Original = %q, want 'client-value'\", capturedHeaders.Get(\"X-Original\"))\n\t\t}\n\t\tif capturedHeaders.Get(\"X-Request-ID\") != \"req-123\" {\n\t\t\tt.Errorf(\"X-Request-ID = %q, want 'req-123'\", capturedHeaders.Get(\"X-Request-ID\"))\n\t\t}\n\t\tif capturedHeaders.Get(\"X-Custom\") != \"value2\" {\n\t\t\tt.Errorf(\"X-Custom = %q, want 'value2' (should be modified)\", capturedHeaders.Get(\"X-Custom\"))\n\t\t}\n\t\tif capturedHeaders.Get(\"X-Middleware-2\") != \"processed\" {\n\t\t\tt.Errorf(\"X-Middleware-2 = %q, want 'processed'\", capturedHeaders.Get(\"X-Middleware-2\"))\n\t\t}\n\t})\n\n\tt.Run(\"middleware can modify URL path\", func(t *testing.T) {\n\t\tvar capturedPath string\n\n\t\trtr := routegroup.New(http.NewServeMux())\n\n\t\t// middleware that modifies URL\n\t\trtr.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t// create a new URL with modified path\n\t\t\t\tnewURL := *r.URL\n\t\t\t\tnewURL.Path = \"/modified\" + r.URL.Path\n\n\t\t\t\t// create new request with modified URL\n\t\t\t\tr2 := r.Clone(r.Context())\n\t\t\t\tr2.URL = &newURL\n\n\t\t\t\tnext.ServeHTTP(w, r2)\n\t\t\t})\n\t\t})\n\n\t\t// register handler for modified path\n\t\trtr.HandleFunc(\"GET /modified/original\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\tcapturedPath = r.URL.Path\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t})\n\n\t\treq, _ := http.NewRequest(http.MethodGet, \"/original\", http.NoBody)\n\t\trec := httptest.NewRecorder()\n\n\t\trtr.ServeHTTP(rec, req)\n\n\t\tif rec.Code != http.StatusOK {\n\t\t\tt.Errorf(\"status = %d, want %d\", rec.Code, http.StatusOK)\n\t\t}\n\t\tif capturedPath != \"/modified/original\" {\n\t\t\tt.Errorf(\"captured path = %q, want '/modified/original'\", capturedPath)\n\t\t}\n\t})\n\n\tt.Run(\"middleware modifications work with mounted groups\", func(t *testing.T) {\n\t\tvar handlerHeaders http.Header\n\n\t\trtr := routegroup.New(http.NewServeMux())\n\n\t\t// root middleware - adds base header\n\t\trtr.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tr.Header.Set(\"X-Root\", \"root-value\")\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t})\n\n\t\tapi := rtr.Mount(\"/api\")\n\n\t\t// api middleware - adds API header\n\t\tapi.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t// verify root header exists\n\t\t\t\tif root := r.Header.Get(\"X-Root\"); root != \"root-value\" {\n\t\t\t\t\tt.Errorf(\"api middleware: X-Root = %q, want 'root-value'\", root)\n\t\t\t\t}\n\n\t\t\t\tr.Header.Set(\"X-API\", \"api-value\")\n\t\t\t\tr.Header.Set(\"X-API-Version\", \"v1\")\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t})\n\n\t\tapi.HandleFunc(\"GET /data\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\thandlerHeaders = r.Header.Clone()\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t})\n\n\t\treq, _ := http.NewRequest(http.MethodGet, \"/api/data\", http.NoBody)\n\t\trec := httptest.NewRecorder()\n\n\t\trtr.ServeHTTP(rec, req)\n\n\t\t// verify all headers made it to handler\n\t\tif handlerHeaders.Get(\"X-Root\") != \"root-value\" {\n\t\t\tt.Errorf(\"X-Root = %q, want 'root-value'\", handlerHeaders.Get(\"X-Root\"))\n\t\t}\n\t\tif handlerHeaders.Get(\"X-API\") != \"api-value\" {\n\t\t\tt.Errorf(\"X-API = %q, want 'api-value'\", handlerHeaders.Get(\"X-API\"))\n\t\t}\n\t\tif handlerHeaders.Get(\"X-API-Version\") != \"v1\" {\n\t\t\tt.Errorf(\"X-API-Version = %q, want 'v1'\", handlerHeaders.Get(\"X-API-Version\"))\n\t\t}\n\t})\n}\n\nfunc TestRequestPatternAndMiddlewareCallCount(t *testing.T) {\n\t// regression test for issue #24 - verify that:\n\t// 1. middlewares are not executed twice\n\t// 2. Request.Pattern is available in global middlewares\n\n\tvar callCount map[string]int\n\tvar mu sync.Mutex\n\n\tpatternLogger := func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tmu.Lock()\n\t\t\tcallCount[r.URL.Path]++\n\t\t\tpatternBefore := r.Pattern\n\t\t\tmu.Unlock()\n\n\t\t\tnext.ServeHTTP(w, r)\n\n\t\t\tmu.Lock()\n\t\t\tpatternAfter := r.Pattern\n\t\t\tmu.Unlock()\n\n\t\t\t// verify pattern is set before calling next handler\n\t\t\tif patternBefore == \"\" {\n\t\t\t\tt.Errorf(\"pattern should be set before ServeHTTP, got empty for path %s\", r.URL.Path)\n\t\t\t}\n\t\t\t// verify pattern remains consistent\n\t\t\tif patternBefore != patternAfter {\n\t\t\t\tt.Errorf(\"pattern changed from %q to %q for path %s\", patternBefore, patternAfter, r.URL.Path)\n\t\t\t}\n\t\t})\n\t}\n\n\thandler := func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t\tid := r.PathValue(\"id\")\n\t\tif id != \"\" {\n\t\t\t_, _ = w.Write([]byte(\"id: \" + id))\n\t\t}\n\t}\n\n\tt.Run(\"root group with path params\", func(t *testing.T) {\n\t\tcallCount = make(map[string]int)\n\t\trtr := routegroup.New(http.NewServeMux())\n\t\trtr.Use(patternLogger)\n\t\trtr.HandleFunc(\"GET /a/{id}\", handler)\n\n\t\trecorder := httptest.NewRecorder()\n\t\trequest, err := http.NewRequest(http.MethodGet, \"/a/123\", http.NoBody)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\trtr.ServeHTTP(recorder, request)\n\n\t\tif recorder.Code != http.StatusOK {\n\t\t\tt.Errorf(\"expected status 200, got %d\", recorder.Code)\n\t\t}\n\t\tif body := recorder.Body.String(); body != \"id: 123\" {\n\t\t\tt.Errorf(\"expected 'id: 123', got %q\", body)\n\t\t}\n\n\t\t// verify middleware was called exactly once\n\t\tif count := callCount[\"/a/123\"]; count != 1 {\n\t\t\tt.Errorf(\"middleware should be called exactly once, but was called %d times\", count)\n\t\t}\n\t})\n\n\tt.Run(\"mounted group with path params\", func(t *testing.T) {\n\t\tcallCount = make(map[string]int)\n\t\trtr := routegroup.New(http.NewServeMux())\n\t\trtr.Use(patternLogger)\n\n\t\tbGroup := rtr.Mount(\"/b\")\n\t\tbGroup.HandleFunc(\"GET /{id}\", handler)\n\n\t\trecorder := httptest.NewRecorder()\n\t\trequest, err := http.NewRequest(http.MethodGet, \"/b/456\", http.NoBody)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\trtr.ServeHTTP(recorder, request)\n\n\t\tif recorder.Code != http.StatusOK {\n\t\t\tt.Errorf(\"expected status 200, got %d\", recorder.Code)\n\t\t}\n\t\tif body := recorder.Body.String(); body != \"id: 456\" {\n\t\t\tt.Errorf(\"expected 'id: 456', got %q\", body)\n\t\t}\n\n\t\t// verify middleware was called exactly once\n\t\tif count := callCount[\"/b/456\"]; count != 1 {\n\t\t\tt.Errorf(\"middleware should be called exactly once for mounted path, but was called %d times\", count)\n\t\t}\n\t})\n\n\tt.Run(\"multiple middlewares see pattern\", func(t *testing.T) {\n\t\tcallCount = make(map[string]int)\n\t\tvar patterns []string\n\t\tvar mu2 sync.Mutex\n\n\t\tmiddleware1 := func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tmu.Lock()\n\t\t\t\tcallCount[\"m1\"]++\n\t\t\t\tmu.Unlock()\n\n\t\t\t\tmu2.Lock()\n\t\t\t\tpatterns = append(patterns, \"m1:\"+r.Pattern)\n\t\t\t\tmu2.Unlock()\n\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t}\n\n\t\tmiddleware2 := func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tmu.Lock()\n\t\t\t\tcallCount[\"m2\"]++\n\t\t\t\tmu.Unlock()\n\n\t\t\t\tmu2.Lock()\n\t\t\t\tpatterns = append(patterns, \"m2:\"+r.Pattern)\n\t\t\t\tmu2.Unlock()\n\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t}\n\n\t\trtr := routegroup.New(http.NewServeMux())\n\t\trtr.Use(middleware1, middleware2)\n\t\trtr.HandleFunc(\"GET /test/{id}\", handler)\n\n\t\trecorder := httptest.NewRecorder()\n\t\trequest, err := http.NewRequest(http.MethodGet, \"/test/789\", http.NoBody)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\trtr.ServeHTTP(recorder, request)\n\n\t\t// verify each middleware called once\n\t\tif count := callCount[\"m1\"]; count != 1 {\n\t\t\tt.Errorf(\"middleware1 should be called once, got %d\", count)\n\t\t}\n\t\tif count := callCount[\"m2\"]; count != 1 {\n\t\t\tt.Errorf(\"middleware2 should be called once, got %d\", count)\n\t\t}\n\n\t\t// verify both middlewares saw the pattern\n\t\tif len(patterns) != 2 {\n\t\t\tt.Errorf(\"expected 2 pattern records, got %d\", len(patterns))\n\t\t}\n\t\tif len(patterns) == 2 {\n\t\t\tif patterns[0] != \"m1:GET /test/{id}\" {\n\t\t\t\tt.Errorf(\"middleware1 pattern = %q, want %q\", patterns[0], \"m1:GET /test/{id}\")\n\t\t\t}\n\t\t\tif patterns[1] != \"m2:GET /test/{id}\" {\n\t\t\t\tt.Errorf(\"middleware2 pattern = %q, want %q\", patterns[1], \"m2:GET /test/{id}\")\n\t\t\t}\n\t\t}\n\t})\n\n\tt.Run(\"route without path params\", func(t *testing.T) {\n\t\tcallCount = make(map[string]int)\n\t\tvar seenPattern string\n\n\t\tcheckPattern := func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tmu.Lock()\n\t\t\t\tcallCount[r.URL.Path]++\n\t\t\t\tseenPattern = r.Pattern\n\t\t\t\tmu.Unlock()\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t}\n\n\t\trtr := routegroup.New(http.NewServeMux())\n\t\trtr.Use(checkPattern)\n\t\trtr.HandleFunc(\"GET /static\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t_, _ = w.Write([]byte(\"static\"))\n\t\t})\n\n\t\trecorder := httptest.NewRecorder()\n\t\trequest, err := http.NewRequest(http.MethodGet, \"/static\", http.NoBody)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\trtr.ServeHTTP(recorder, request)\n\n\t\tif count := callCount[\"/static\"]; count != 1 {\n\t\t\tt.Errorf(\"middleware should be called once for static route, got %d\", count)\n\t\t}\n\n\t\tif seenPattern != \"GET /static\" {\n\t\t\tt.Errorf(\"pattern = %q, want %q\", seenPattern, \"GET /static\")\n\t\t}\n\t})\n}\n\n// TestRequestIsolation verifies that the original request passed to ServeHTTP\n// is not modified, ensuring proper isolation through shallow copy.\nfunc TestRequestIsolation(t *testing.T) {\n\tt.Run(\"original request not modified\", func(t *testing.T) {\n\t\trouter := routegroup.New(http.NewServeMux())\n\n\t\tvar middlewareRequest *http.Request\n\n\t\t// middleware that captures the request object\n\t\trouter.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tmiddlewareRequest = r\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t})\n\n\t\trouter.HandleFunc(\"GET /test/{id}\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t})\n\n\t\t// create the original request\n\t\toriginalRequest, err := http.NewRequest(http.MethodGet, \"/test/123\", http.NoBody)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\t// save original state\n\t\toriginalPattern := originalRequest.Pattern\n\n\t\t// make the request\n\t\trecorder := httptest.NewRecorder()\n\t\trouter.ServeHTTP(recorder, originalRequest)\n\n\t\t// verify the original request was not modified\n\t\tif originalRequest.Pattern != originalPattern {\n\t\t\tt.Errorf(\"original request was modified: Pattern changed from %q to %q\",\n\t\t\t\toriginalPattern, originalRequest.Pattern)\n\t\t}\n\n\t\t// verify middleware received a different request object (shallow copy)\n\t\tif middlewareRequest == originalRequest {\n\t\t\tt.Error(\"middleware received the same request object (expected a copy)\")\n\t\t}\n\n\t\t// verify middleware's request has the pattern set\n\t\tif middlewareRequest.Pattern == \"\" {\n\t\t\tt.Error(\"middleware's request should have Pattern set\")\n\t\t}\n\t})\n\n\tt.Run(\"isolation with 404\", func(t *testing.T) {\n\t\trouter := routegroup.New(http.NewServeMux())\n\n\t\trouter.NotFoundHandler(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t})\n\n\t\trouter.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tw.Header().Set(\"X-Middleware\", \"ran\")\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t})\n\n\t\toriginalRequest, err := http.NewRequest(http.MethodGet, \"/non-existent\", http.NoBody)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\toriginalPattern := originalRequest.Pattern\n\n\t\trecorder := httptest.NewRecorder()\n\t\trouter.ServeHTTP(recorder, originalRequest)\n\n\t\tif recorder.Code != http.StatusNotFound {\n\t\t\tt.Errorf(\"expected 404, got %d\", recorder.Code)\n\t\t}\n\n\t\t// original request should not be modified\n\t\tif originalRequest.Pattern != originalPattern {\n\t\t\tt.Errorf(\"original request was modified: Pattern changed from %q to %q\",\n\t\t\t\toriginalPattern, originalRequest.Pattern)\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "mount_test.go",
    "content": "package routegroup_test\n\nimport (\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"reflect\"\n\t\"testing\"\n\n\t\"github.com/go-pkgz/routegroup\"\n)\n\nfunc TestMount(t *testing.T) {\n\tbasePath := \"/api\"\n\tgroup := routegroup.Mount(http.NewServeMux(), basePath)\n\n\tgroup.Use(func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tw.Header().Add(\"X-Mounted-Middleware\", \"true\")\n\t\t\tnext.ServeHTTP(w, r)\n\t\t})\n\t})\n\n\tgroup.HandleFunc(\"/test\", func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t})\n\n\trecorder := httptest.NewRecorder()\n\trequest, err := http.NewRequest(http.MethodGet, basePath+\"/test\", http.NoBody)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tgroup.ServeHTTP(recorder, request)\n\n\tif recorder.Code != http.StatusOK {\n\t\tt.Errorf(\"Expected status code %d, got %d\", http.StatusOK, recorder.Code)\n\t}\n\tif header := recorder.Header().Get(\"X-Mounted-Middleware\"); header != \"true\" {\n\t\tt.Errorf(\"Expected header X-Mounted-Middleware to be 'true', got '%s'\", header)\n\t}\n}\n\nfunc TestHTTPServerWithBasePathAndMiddleware(t *testing.T) {\n\tgroup := routegroup.Mount(http.NewServeMux(), \"/api\")\n\n\tgroup.Use(func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tw.Header().Add(\"X-Test-Middleware\", \"applied\")\n\t\t\tnext.ServeHTTP(w, r)\n\t\t})\n\t})\n\n\tgroup.HandleFunc(\"/test\", func(w http.ResponseWriter, _ *http.Request) {\n\t\t_, _ = w.Write([]byte(\"test handler\"))\n\t})\n\n\ttestServer := httptest.NewServer(group)\n\tdefer testServer.Close()\n\n\tresp, err := http.Get(testServer.URL + \"/api/test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif string(body) != \"test handler\" {\n\t\tt.Errorf(\"Expected body 'test handler', got '%s'\", string(body))\n\t}\n\tif header := resp.Header.Get(\"X-Test-Middleware\"); header != \"applied\" {\n\t\tt.Errorf(\"Expected header X-Test-Middleware to be 'applied', got '%s'\", header)\n\t}\n}\n\nfunc TestHTTPServerWithBasePathNoMiddleware(t *testing.T) {\n\tgroup := routegroup.Mount(http.NewServeMux(), \"/api\")\n\tgroup.HandleFunc(\"/test\", func(w http.ResponseWriter, _ *http.Request) {\n\t\t_, _ = w.Write([]byte(\"test handler\"))\n\t})\n\n\ttestServer := httptest.NewServer(group)\n\tdefer testServer.Close()\n\n\tresp, err := http.Get(testServer.URL + \"/api/test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif string(body) != \"test handler\" {\n\t\tt.Errorf(\"Expected body 'test handler', got '%s'\", string(body))\n\t}\n}\n\nfunc TestHTTPServerWithDerived(t *testing.T) {\n\t// create a new bundle with default middleware\n\tbundle := routegroup.New(http.NewServeMux())\n\tbundle.NotFoundHandler(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusNotFound)\n\t\t_, _ = w.Write([]byte(\"not found handler\"))\n\t})\n\tbundle.Use(testMiddleware)\n\n\t// mount a group with additional middleware on /api\n\tgroup1 := bundle.Mount(\"/api\")\n\tgroup1.Use(func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tw.Header().Add(\"X-API-Middleware\", \"applied\")\n\t\t\tnext.ServeHTTP(w, r)\n\t\t})\n\t})\n\n\tgroup1.HandleFunc(\"GET /test\", func(w http.ResponseWriter, _ *http.Request) {\n\t\t_, _ = w.Write([]byte(\"GET test method handler\"))\n\t})\n\tgroup1.HandleFunc(\"POST /\", func(w http.ResponseWriter, _ *http.Request) {\n\t\t_, _ = w.Write([]byte(\"POST api / method handler\"))\n\t})\n\n\t// add another group with middleware\n\tbundle.Group().Route(func(g *routegroup.Bundle) {\n\t\tg.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tw.Header().Add(\"X-Blah-Middleware\", \"true\")\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t})\n\t\tg.HandleFunc(\"GET /blah/blah\", func(w http.ResponseWriter, _ *http.Request) {\n\t\t\t_, _ = w.Write([]byte(\"GET blah method handler\"))\n\t\t})\n\t})\n\n\t// mount the bundle on /auth under /api\n\tgroup1.Mount(\"/auth\").Route(func(g *routegroup.Bundle) {\n\t\tg.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tw.Header().Add(\"X-Auth-Middleware\", \"true\")\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t})\n\t\tg.HandleFunc(\"GET /auth-test\", func(w http.ResponseWriter, _ *http.Request) {\n\t\t\t_, _ = w.Write([]byte(\"GET auth-test method handler\"))\n\t\t})\n\t\tg.HandleFunc(\"GET /\", func(w http.ResponseWriter, _ *http.Request) {\n\t\t\t_, _ = w.Write([]byte(\"auth GET / method handler\"))\n\t\t})\n\t})\n\n\ttestServer := httptest.NewServer(bundle)\n\tdefer testServer.Close()\n\n\tt.Run(\"GET /api/test\", func(t *testing.T) {\n\t\tresp, err := http.Get(testServer.URL + \"/api/test\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif string(body) != \"GET test method handler\" {\n\t\t\tt.Errorf(\"Expected body 'GET test method handler', got '%s'\", string(body))\n\t\t}\n\t\tif header := resp.Header.Get(\"X-Test-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-Test-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t})\n\n\tt.Run(\"GET /blah/blah\", func(t *testing.T) {\n\t\tresp, err := http.Get(testServer.URL + \"/blah/blah\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif string(body) != \"GET blah method handler\" {\n\t\t\tt.Errorf(\"Expected body 'GET blah method handler', got '%s'\", string(body))\n\t\t}\n\t\tif header := resp.Header.Get(\"X-Blah-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-Blah-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t\tif header := resp.Header.Get(\"X-Test-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-Test-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t})\n\n\tt.Run(\"GET /api/auth/auth-test\", func(t *testing.T) {\n\t\tresp, err := http.Get(testServer.URL + \"/api/auth/auth-test\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif string(body) != \"GET auth-test method handler\" {\n\t\t\tt.Errorf(\"Expected body 'GET auth-test method handler', got '%s'\", string(body))\n\t\t}\n\t\tif header := resp.Header.Get(\"X-Auth-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-Auth-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t\tif header := resp.Header.Get(\"X-Test-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-Test-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t})\n\n\tt.Run(\"GET /api/auth/\", func(t *testing.T) {\n\t\tresp, err := http.Get(testServer.URL + \"/api/auth/\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif string(body) != \"auth GET / method handler\" {\n\t\t\tt.Errorf(\"Expected body 'GET auth-test method handler', got '%s'\", string(body))\n\t\t}\n\t\tif header := resp.Header.Get(\"X-Auth-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-Auth-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t\tif header := resp.Header.Get(\"X-Test-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-Test-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t})\n\n\tt.Run(\"POST /api/\", func(t *testing.T) {\n\t\tresp, err := http.Post(testServer.URL+\"/api/\", \"application/json\", http.NoBody)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\tt.Errorf(\"Expected status code %d, got %d\", http.StatusOK, resp.StatusCode)\n\t\t}\n\t\tif string(body) != \"POST api / method handler\" {\n\t\t\tt.Errorf(\"Expected body 'GET auth-test method handler', got '%s'\", string(body))\n\t\t}\n\t\tif header := resp.Header.Get(\"X-Auth-Middleware\"); header != \"\" {\n\t\t\tt.Errorf(\"Expected header X-Auth-Middleware to be empty, got '%s'\", header)\n\t\t}\n\t\tif header := resp.Header.Get(\"X-Test-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-Test-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t})\n\n\tt.Run(\"POST /api/not-found\", func(t *testing.T) {\n\t\tresp, err := http.Post(testServer.URL+\"/api/not-found\", \"application/json\", http.NoBody)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif resp.StatusCode != http.StatusNotFound {\n\t\t\tt.Errorf(\"Expected status code %d, got %d\", http.StatusNotFound, resp.StatusCode)\n\t\t}\n\t\tif string(body) != \"not found handler\" {\n\t\t\tt.Errorf(\"Expected body '404 page not found', got '%s'\", string(body))\n\t\t}\n\t\tif header := resp.Header.Get(\"X-Auth-Middleware\"); header != \"\" {\n\t\t\tt.Errorf(\"Expected header X-Auth-Middleware to be empty, got '%s'\", header)\n\t\t}\n\t\tif header := resp.Header.Get(\"X-Test-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-Test-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t})\n\n\tt.Run(\"GET /api/\", func(t *testing.T) {\n\t\tresp, err := http.Get(testServer.URL + \"/api/\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\t\t// should return 405 Method Not Allowed since POST / is registered but not GET /\n\t\tif resp.StatusCode != http.StatusMethodNotAllowed {\n\t\t\tt.Errorf(\"Expected status code %d, got %d\", http.StatusMethodNotAllowed, resp.StatusCode)\n\t\t}\n\t\t// 405 response should include Allow header\n\t\tif allowHeader := resp.Header.Get(\"Allow\"); allowHeader == \"\" {\n\t\t\tt.Error(\"Expected Allow header for 405 response\")\n\t\t}\n\t\tif header := resp.Header.Get(\"X-Auth-Middleware\"); header != \"\" {\n\t\t\tt.Errorf(\"Expected header X-Auth-Middleware to be empty, got '%s'\", header)\n\t\t}\n\t\tif header := resp.Header.Get(\"X-Test-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-Test-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t})\n\n\tt.Run(\"GET /not-found\", func(t *testing.T) {\n\t\tresp, err := http.Get(testServer.URL + \"/not-found\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\t\tif resp.StatusCode != http.StatusNotFound {\n\t\t\tt.Errorf(\"Expected status code %d, got %d\", http.StatusNotFound, resp.StatusCode)\n\t\t}\n\t\tif header := resp.Header.Get(\"X-Test-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-Test-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t})\n}\n\nfunc TestMountNested(t *testing.T) {\n\tbundle := routegroup.New(http.NewServeMux())\n\tapi := bundle.Mount(\"/api\")\n\tv1 := api.Mount(\"/v1\")\n\tv1.HandleFunc(\"/test\", func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t\tif _, err := w.Write([]byte(\"v1 test\")); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t})\n\n\trec := httptest.NewRecorder()\n\treq, _ := http.NewRequest(http.MethodGet, \"/api/v1/test\", http.NoBody)\n\tbundle.ServeHTTP(rec, req)\n\tif rec.Code != http.StatusOK {\n\t\tt.Errorf(\"got %d, want %d\", rec.Code, http.StatusOK)\n\t}\n\tif rec.Body.String() != \"v1 test\" {\n\t\tt.Errorf(\"got %q, want %q\", rec.Body.String(), \"v1 test\")\n\t}\n}\n\nfunc TestMountPointMethodConflicts(t *testing.T) {\n\tgroup := routegroup.New(http.NewServeMux())\n\n\t// register handler for /api directly\n\tgroup.HandleFunc(\"GET /api\", func(w http.ResponseWriter, _ *http.Request) {\n\t\t_, _ = w.Write([]byte(\"api root\"))\n\t})\n\n\t// mount a group at /api\n\tapi := group.Mount(\"/api\")\n\tapi.HandleFunc(\"/users\", func(w http.ResponseWriter, _ *http.Request) {\n\t\t_, _ = w.Write([]byte(\"users\"))\n\t})\n\n\tsrv := httptest.NewServer(group)\n\tdefer srv.Close()\n\n\tt.Run(\"get /api root\", func(t *testing.T) {\n\t\tresp, err := http.Get(srv.URL + \"/api\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\tt.Errorf(\"expected status 200, got %d\", resp.StatusCode)\n\t\t}\n\t\tif string(body) != \"api root\" {\n\t\t\tt.Errorf(\"expected 'api root', got %q\", string(body))\n\t\t}\n\t})\n\n\tt.Run(\"get /api/users\", func(t *testing.T) {\n\t\tresp, err := http.Get(srv.URL + \"/api/users\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\tt.Errorf(\"expected status 200, got %d\", resp.StatusCode)\n\t\t}\n\t\tif string(body) != \"users\" {\n\t\t\tt.Errorf(\"expected 'users', got %q\", string(body))\n\t\t}\n\t})\n}\n\nfunc TestDeepNestedMounts(t *testing.T) {\n\tvar callOrder []string\n\tmkMiddleware := func(name string) func(http.Handler) http.Handler {\n\t\treturn func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tcallOrder = append(callOrder, \"before \"+name)\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\tcallOrder = append(callOrder, \"after \"+name)\n\t\t\t})\n\t\t}\n\t}\n\n\tgroup := routegroup.New(http.NewServeMux())\n\tgroup.Use(mkMiddleware(\"root\"))\n\n\tv1 := group.Mount(\"/v1\")\n\tv1.Use(mkMiddleware(\"v1\"))\n\n\tapi := v1.Mount(\"/api\")\n\tapi.Use(mkMiddleware(\"api\"))\n\n\tusers := api.Mount(\"/users\")\n\tusers.Use(mkMiddleware(\"users\"))\n\n\tusers.HandleFunc(\"/list\", func(w http.ResponseWriter, _ *http.Request) {\n\t\tcallOrder = append(callOrder, \"handler\")\n\t\t_, _ = w.Write([]byte(\"users list\"))\n\t})\n\n\tsrv := httptest.NewServer(group)\n\tdefer srv.Close()\n\n\tresp, err := http.Get(srv.URL + \"/v1/api/users/list\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tt.Errorf(\"expected status 200, got %d\", resp.StatusCode)\n\t}\n\tif string(body) != \"users list\" {\n\t\tt.Errorf(\"expected 'users list', got %q\", string(body))\n\t}\n\n\texpected := []string{\n\t\t\"before root\",\n\t\t\"before v1\",\n\t\t\"before api\",\n\t\t\"before users\",\n\t\t\"handler\",\n\t\t\"after users\",\n\t\t\"after api\",\n\t\t\"after v1\",\n\t\t\"after root\",\n\t}\n\n\tif !reflect.DeepEqual(callOrder, expected) {\n\t\tt.Errorf(\"middleware execution order mismatch\\nwant: %v\\ngot:  %v\", expected, callOrder)\n\t}\n}\n\n// TestSubgroupRootPathMatching tests that a subgroup with a root path pattern (/)\n// properly matches requests to the exact path without a trailing slash.\n\nfunc TestSubgroupRootPathMatching(t *testing.T) {\n\tmux := http.NewServeMux()\n\trouter := routegroup.New(mux)\n\n\t// create a mounted group at /api/v1/users\n\tusersGroup := router.Mount(\"/api/v1/users\")\n\n\t// add middleware to the group to test middleware invocation\n\tusersGroup.Use(func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tw.Header().Set(\"X-Users-Middleware\", \"applied\")\n\t\t\tnext.ServeHTTP(w, r)\n\t\t})\n\t})\n\n\t// register handler for the root of the mounted group using \"/\"\n\tusersGroup.HandleFunc(\"GET /\", func(w http.ResponseWriter, _ *http.Request) {\n\t\t_, _ = w.Write([]byte(\"users root\"))\n\t})\n\n\t// also add a child route for comparison\n\tusersGroup.HandleFunc(\"GET /list\", func(w http.ResponseWriter, _ *http.Request) {\n\t\t_, _ = w.Write([]byte(\"users list\"))\n\t})\n\n\tsrv := httptest.NewServer(router)\n\tdefer srv.Close()\n\n\tt.Run(\"exact match without trailing slash\", func(t *testing.T) {\n\t\tresp, err := http.Get(srv.URL + \"/api/v1/users\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\tt.Errorf(\"expected status 200, got %d\", resp.StatusCode)\n\t\t}\n\t\tif string(body) != \"users root\" {\n\t\t\tt.Errorf(\"expected 'users root', got %q\", string(body))\n\t\t}\n\n\t\t// check middleware was applied\n\t\tmiddlewareHeader := resp.Header.Get(\"X-Users-Middleware\")\n\t\tif middlewareHeader != \"applied\" {\n\t\t\tt.Errorf(\"expected middleware header to be 'applied', got %q\", middlewareHeader)\n\t\t}\n\t})\n\n\tt.Run(\"with trailing slash\", func(t *testing.T) {\n\t\tresp, err := http.Get(srv.URL + \"/api/v1/users/\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\tt.Errorf(\"expected status 200, got %d\", resp.StatusCode)\n\t\t}\n\t\tif string(body) != \"users root\" {\n\t\t\tt.Errorf(\"expected 'users root', got %q\", string(body))\n\t\t}\n\n\t\t// check middleware was applied\n\t\tmiddlewareHeader := resp.Header.Get(\"X-Users-Middleware\")\n\t\tif middlewareHeader != \"applied\" {\n\t\t\tt.Errorf(\"expected middleware header to be 'applied', got %q\", middlewareHeader)\n\t\t}\n\t})\n\n\tt.Run(\"child route\", func(t *testing.T) {\n\t\tresp, err := http.Get(srv.URL + \"/api/v1/users/list\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\tt.Errorf(\"expected status 200, got %d\", resp.StatusCode)\n\t\t}\n\t\tif string(body) != \"users list\" {\n\t\t\tt.Errorf(\"expected 'users list', got %q\", string(body))\n\t\t}\n\n\t\t// check middleware was applied\n\t\tmiddlewareHeader := resp.Header.Get(\"X-Users-Middleware\")\n\t\tif middlewareHeader != \"applied\" {\n\t\t\tt.Errorf(\"expected middleware header to be 'applied', got %q\", middlewareHeader)\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "notfound_test.go",
    "content": "package routegroup_test\n\nimport (\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/go-pkgz/routegroup\"\n)\n\nfunc TestHTTPServerWithCustomNotFound(t *testing.T) {\n\tgroup := routegroup.New(http.NewServeMux())\n\tgroup.Use(testMiddleware)\n\tgroup.NotFoundHandler(func(w http.ResponseWriter, _ *http.Request) {\n\t\thttp.Error(w, \"Custom 404: Page not found!\", http.StatusNotFound)\n\t})\n\n\tapiGroup := group.Mount(\"/api\")\n\tapiGroup.HandleFunc(\"GET /test\", func(w http.ResponseWriter, _ *http.Request) {\n\t\t_, _ = w.Write([]byte(\"test handler\"))\n\t})\n\n\ttestServer := httptest.NewServer(group)\n\tdefer testServer.Close()\n\n\tt.Run(\"GET /api/test\", func(t *testing.T) {\n\t\tresp, err := http.Get(testServer.URL + \"/api/test\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\tt.Errorf(\"Expected status code %d, got %d\", http.StatusOK, resp.StatusCode)\n\t\t}\n\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif string(body) != \"test handler\" {\n\t\t\tt.Errorf(\"Expected body 'test handler', got '%s'\", string(body))\n\t\t}\n\t\tif header := resp.Header.Get(\"X-Test-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-Test-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t})\n\n\tt.Run(\"GET /api/not-found\", func(t *testing.T) {\n\t\tresp, err := http.Get(testServer.URL + \"/api/not-found\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tt.Logf(\"body: %s\", body)\n\n\t\tif resp.StatusCode != http.StatusNotFound {\n\t\t\tt.Errorf(\"Expected status code %d, got %d\", http.StatusNotFound, resp.StatusCode)\n\t\t}\n\n\t\tif header := resp.Header.Get(\"X-Test-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-Test-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t\tif string(body) != \"Custom 404: Page not found!\\n\" {\n\t\t\tt.Errorf(\"Expected body 'Custom 404: Page not found!', got '%s'\", string(body))\n\t\t}\n\t})\n\n\tt.Run(\"GET /not-found\", func(t *testing.T) {\n\t\tresp, err := http.Get(testServer.URL + \"/not-found\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tt.Logf(\"body: %s\", body)\n\n\t\tif resp.StatusCode != http.StatusNotFound {\n\t\t\tt.Errorf(\"Expected status code %d, got %d\", http.StatusNotFound, resp.StatusCode)\n\t\t}\n\t\tif header := resp.Header.Get(\"X-Test-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-Test-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t\tif string(body) != \"Custom 404: Page not found!\\n\" {\n\t\t\tt.Errorf(\"Expected body 'Custom 404: Page not found!', got '%s'\", string(body))\n\t\t}\n\t})\n}\n\nfunc TestHTTPServerWithCustomNotFoundNon404Status(t *testing.T) {\n\tgroup := routegroup.New(http.NewServeMux())\n\tgroup.Use(testMiddleware)\n\tgroup.NotFoundHandler(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusServiceUnavailable)\n\t\t_, _ = w.Write([]byte(\"Custom 404: Page not found!\\n\"))\n\t})\n\n\tapiGroup := group.Mount(\"/api\")\n\tapiGroup.HandleFunc(\"GET /test\", func(w http.ResponseWriter, _ *http.Request) {\n\t\t_, _ = w.Write([]byte(\"test handler\"))\n\t})\n\n\ttestServer := httptest.NewServer(group)\n\tdefer testServer.Close()\n\n\tt.Run(\"GET /api/test\", func(t *testing.T) {\n\t\tresp, err := http.Get(testServer.URL + \"/api/test\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\tt.Errorf(\"Expected status code %d, got %d\", http.StatusOK, resp.StatusCode)\n\t\t}\n\t\tif string(body) != \"test handler\" {\n\t\t\tt.Errorf(\"Expected body 'test handler', got '%s'\", string(body))\n\t\t}\n\t\tif header := resp.Header.Get(\"X-Test-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-Test-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t})\n\n\tt.Run(\"GET /api/not-found\", func(t *testing.T) {\n\t\tresp, err := http.Get(testServer.URL + \"/api/not-found\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tt.Logf(\"body: %s\", body)\n\n\t\tif resp.StatusCode != http.StatusServiceUnavailable {\n\t\t\tt.Errorf(\"Expected status code %d, got %d\", http.StatusServiceUnavailable, resp.StatusCode)\n\t\t}\n\t\tif header := resp.Header.Get(\"X-Test-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-Test-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t\tif string(body) != \"Custom 404: Page not found!\\n\" {\n\t\t\tt.Errorf(\"Expected body 'Custom 404: Page not found!', got '%s'\", string(body))\n\t\t}\n\t})\n}\n\nfunc TestCustomNotFoundHandlerChange(t *testing.T) {\n\tgroup := routegroup.New(http.NewServeMux())\n\tgroup.NotFoundHandler(func(w http.ResponseWriter, _ *http.Request) {\n\t\thttp.Error(w, \"First handler\", http.StatusNotFound)\n\t})\n\n\tgroup.NotFoundHandler(func(w http.ResponseWriter, _ *http.Request) {\n\t\thttp.Error(w, \"Second handler\", http.StatusNotFound)\n\t})\n\n\trec := httptest.NewRecorder()\n\treq, _ := http.NewRequest(http.MethodGet, \"/not-found\", http.NoBody)\n\tgroup.ServeHTTP(rec, req)\n\tif rec.Code != http.StatusNotFound {\n\t\tt.Errorf(\"got %d, want %d\", rec.Code, http.StatusNotFound)\n\t}\n\tif rec.Body.String() != \"Second handler\\n\" {\n\t\tt.Errorf(\"got %q, want %q\", rec.Body.String(), \"Second handler\\n\")\n\t}\n}\n\nfunc TestDisableNotFoundHandlerAfterRouteRegistration(t *testing.T) {\n\tgroup := routegroup.New(http.NewServeMux())\n\tgroup.HandleFunc(\"/test\", func(w http.ResponseWriter, _ *http.Request) {\n\t\tif _, err := w.Write([]byte(\"test\")); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t})\n\tgroup.DisableNotFoundHandler()\n\n\trec := httptest.NewRecorder()\n\treq, _ := http.NewRequest(http.MethodGet, \"/not-found\", http.NoBody)\n\tgroup.ServeHTTP(rec, req)\n\tif rec.Code != http.StatusNotFound {\n\t\tt.Errorf(\"got %d, want %d\", rec.Code, http.StatusNotFound)\n\t}\n\tif rec.Body.String() != \"404 page not found\\n\" {\n\t\tt.Errorf(\"got %q, want %q\", rec.Body.String(), \"404 page not found\\n\")\n\t}\n}\n\nfunc TestNotFoundHandlerOnMountedGroup(t *testing.T) {\n\t// test that NotFoundHandler sets handler on root when called on mounted group\n\troot := routegroup.New(http.NewServeMux())\n\tmounted := root.Mount(\"/api\")\n\n\t// set NotFoundHandler on mounted group - should set it on root\n\tmounted.NotFoundHandler(func(w http.ResponseWriter, _ *http.Request) {\n\t\thttp.Error(w, \"Custom 404 from mounted\", http.StatusNotFound)\n\t})\n\n\t// add a route to the mounted group\n\tmounted.HandleFunc(\"/test\", func(w http.ResponseWriter, _ *http.Request) {\n\t\t_, _ = w.Write([]byte(\"test\"))\n\t})\n\n\ttestServer := httptest.NewServer(root)\n\tdefer testServer.Close()\n\n\t// test that custom 404 works for non-matching routes\n\tresp, err := http.Get(testServer.URL + \"/unknown\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif resp.StatusCode != http.StatusNotFound {\n\t\tt.Errorf(\"got status %d, want %d\", resp.StatusCode, http.StatusNotFound)\n\t}\n\tif string(body) != \"Custom 404 from mounted\\n\" {\n\t\tt.Errorf(\"got body %q, want %q\", string(body), \"Custom 404 from mounted\\n\")\n\t}\n}\n\nfunc TestStatusRecorderWith200Default(t *testing.T) {\n\t// test that statusRecorder correctly identifies handlers that return 200 without explicit WriteHeader\n\tgroup := routegroup.New(http.NewServeMux())\n\n\t// set custom NotFound handler\n\tgroup.NotFoundHandler(func(w http.ResponseWriter, _ *http.Request) {\n\t\thttp.Error(w, \"Custom 404\", http.StatusNotFound)\n\t})\n\n\t// register a handler that returns 200 without calling WriteHeader explicitly\n\t// this is common practice - handlers often just call Write() which implicitly sets 200\n\tgroup.HandleFunc(\"GET /implicit200\", func(w http.ResponseWriter, _ *http.Request) {\n\t\t_, _ = w.Write([]byte(\"success\")) // no WriteHeader call, should be 200\n\t})\n\n\ttestServer := httptest.NewServer(group)\n\tdefer testServer.Close()\n\n\t// test that the handler works correctly and isn't mistaken for a 404\n\tresp, err := http.Get(testServer.URL + \"/implicit200\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, _ := io.ReadAll(resp.Body)\n\n\t// this should return 200 with \"success\" body\n\tif resp.StatusCode != http.StatusOK {\n\t\tt.Errorf(\"expected status 200, got %d\", resp.StatusCode)\n\t}\n\n\tif string(body) != \"success\" {\n\t\tt.Errorf(\"expected body 'success', got %q\", string(body))\n\t}\n\n\t// verify it didn't trigger the custom 404 handler\n\tif string(body) == \"Custom 404\\n\" {\n\t\tt.Error(\"custom 404 handler was incorrectly triggered for a valid 200 response\")\n\t}\n}\n\nfunc TestCustomNotFoundVsMethodNotAllowed(t *testing.T) {\n\t// test demonstrates issue #27 - custom NotFound handler should not override 405 Method Not Allowed\n\tt.Run(\"without custom NotFound handler\", func(t *testing.T) {\n\t\tgroup := routegroup.New(http.NewServeMux())\n\n\t\t// register a route for GET method only\n\t\tgroup.HandleFunc(\"GET /api/resource\", func(w http.ResponseWriter, _ *http.Request) {\n\t\t\t_, _ = w.Write([]byte(\"GET response\"))\n\t\t})\n\n\t\ttestServer := httptest.NewServer(group)\n\t\tdefer testServer.Close()\n\n\t\t// test POST to the same path - should return 405\n\t\treq, _ := http.NewRequest(http.MethodPost, testServer.URL+\"/api/resource\", http.NoBody)\n\t\tclient := &http.Client{}\n\t\tresp, err := client.Do(req)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tbody, _ := io.ReadAll(resp.Body)\n\t\tt.Logf(\"without custom NotFound: status=%d, body=%s\", resp.StatusCode, body)\n\n\t\t// this should return 405 Method Not Allowed\n\t\tif resp.StatusCode != http.StatusMethodNotAllowed {\n\t\t\tt.Errorf(\"expected status %d (Method Not Allowed), got %d\", http.StatusMethodNotAllowed, resp.StatusCode)\n\t\t}\n\n\t\t// test that Allow header is present\n\t\tallowHeader := resp.Header.Get(\"Allow\")\n\t\tif allowHeader == \"\" {\n\t\t\tt.Error(\"expected Allow header to be present for 405 response\")\n\t\t} else {\n\t\t\tt.Logf(\"Allow header: %s\", allowHeader)\n\t\t}\n\t})\n\n\tt.Run(\"with custom NotFound handler\", func(t *testing.T) {\n\t\tgroup := routegroup.New(http.NewServeMux())\n\n\t\t// set custom NotFound handler\n\t\tgroup.NotFoundHandler(func(w http.ResponseWriter, _ *http.Request) {\n\t\t\thttp.Error(w, \"Custom 404: Not Found\", http.StatusNotFound)\n\t\t})\n\n\t\t// register a route for GET method only\n\t\tgroup.HandleFunc(\"GET /api/resource\", func(w http.ResponseWriter, _ *http.Request) {\n\t\t\t_, _ = w.Write([]byte(\"GET response\"))\n\t\t})\n\n\t\ttestServer := httptest.NewServer(group)\n\t\tdefer testServer.Close()\n\n\t\t// test POST to the same path - should still return 405, not 404\n\t\treq, _ := http.NewRequest(http.MethodPost, testServer.URL+\"/api/resource\", http.NoBody)\n\t\tclient := &http.Client{}\n\t\tresp, err := client.Do(req)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tbody, _ := io.ReadAll(resp.Body)\n\t\tt.Logf(\"with custom NotFound: status=%d, body=%s\", resp.StatusCode, body)\n\n\t\t// this should return 405 Method Not Allowed, but might return 404 if the issue exists\n\t\tif resp.StatusCode != http.StatusMethodNotAllowed {\n\t\t\tt.Errorf(\"expected status %d (Method Not Allowed), got %d - custom NotFound handler incorrectly overrides 405\",\n\t\t\t\thttp.StatusMethodNotAllowed, resp.StatusCode)\n\t\t}\n\n\t\t// test that Allow header is present\n\t\tallowHeader := resp.Header.Get(\"Allow\")\n\t\tif allowHeader == \"\" && resp.StatusCode == http.StatusMethodNotAllowed {\n\t\t\tt.Error(\"expected Allow header to be present for 405 response\")\n\t\t} else if allowHeader != \"\" {\n\t\t\tt.Logf(\"Allow header: %s\", allowHeader)\n\t\t}\n\n\t\t// verify the body is not the custom 404 message when it should be 405\n\t\tif resp.StatusCode == http.StatusNotFound && string(body) == \"Custom 404: Not Found\\n\" {\n\t\t\tt.Error(\"custom NotFound handler was incorrectly called for a method mismatch (should be 405)\")\n\t\t}\n\t})\n\n\t// additional test case: verify that actual 404 still uses custom handler\n\tt.Run(\"verify actual 404 uses custom handler\", func(t *testing.T) {\n\t\tgroup := routegroup.New(http.NewServeMux())\n\n\t\t// set custom NotFound handler\n\t\tgroup.NotFoundHandler(func(w http.ResponseWriter, _ *http.Request) {\n\t\t\thttp.Error(w, \"Custom 404: Not Found\", http.StatusNotFound)\n\t\t})\n\n\t\t// register a route for GET method\n\t\tgroup.HandleFunc(\"GET /api/resource\", func(w http.ResponseWriter, _ *http.Request) {\n\t\t\t_, _ = w.Write([]byte(\"GET response\"))\n\t\t})\n\n\t\ttestServer := httptest.NewServer(group)\n\t\tdefer testServer.Close()\n\n\t\t// test a completely non-existent path - should use custom 404\n\t\tresp, err := http.Get(testServer.URL + \"/api/nonexistent\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tbody, _ := io.ReadAll(resp.Body)\n\t\tt.Logf(\"actual 404: status=%d, body=%s\", resp.StatusCode, body)\n\n\t\tif resp.StatusCode != http.StatusNotFound {\n\t\t\tt.Errorf(\"expected status %d, got %d\", http.StatusNotFound, resp.StatusCode)\n\t\t}\n\n\t\t// verify custom 404 message is used\n\t\tif string(body) != \"Custom 404: Not Found\\n\" {\n\t\t\tt.Errorf(\"expected custom 404 body, got %q\", string(body))\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "pathparams_test.go",
    "content": "package routegroup_test\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/go-pkgz/routegroup\"\n)\n\nfunc TestPathParametersWithMount(t *testing.T) {\n\ttests := []struct {\n\t\tname           string\n\t\tmethod         string\n\t\tsetupFunc      func() *routegroup.Bundle\n\t\trequestPath    string\n\t\texpectedParam  string\n\t\texpectedStatus int\n\t}{\n\t\t{\n\t\t\tname:   \"path parameters with mount\",\n\t\t\tmethod: \"POST\",\n\t\t\tsetupFunc: func() *routegroup.Bundle {\n\t\t\t\tmux := http.NewServeMux()\n\t\t\t\tbundle := routegroup.Mount(mux, \"/api/v0\")\n\t\t\t\tpeerGroup := bundle.Mount(\"/peer\")\n\t\t\t\tpeerGroup.HandleFunc(\"POST /iface/{iface}/multiplenew\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t\tinterfaceID := r.PathValue(\"iface\")\n\t\t\t\t\t_, _ = w.Write([]byte(\"iface=\" + interfaceID))\n\t\t\t\t})\n\t\t\t\treturn bundle\n\t\t\t},\n\t\t\trequestPath:    \"/api/v0/peer/iface/test123/multiplenew\",\n\t\t\texpectedParam:  \"iface=test123\",\n\t\t\texpectedStatus: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tname:   \"path parameters with group\",\n\t\t\tmethod: \"POST\",\n\t\t\tsetupFunc: func() *routegroup.Bundle {\n\t\t\t\tmux := http.NewServeMux()\n\t\t\t\tbundle := routegroup.Mount(mux, \"/api/v0\")\n\t\t\t\tpeerGroup := bundle.Group()\n\t\t\t\tpeerGroup.HandleFunc(\"POST /peer/iface/{iface}/multiplenew\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t\tinterfaceID := r.PathValue(\"iface\")\n\t\t\t\t\t_, _ = w.Write([]byte(\"iface=\" + interfaceID))\n\t\t\t\t})\n\t\t\t\treturn bundle\n\t\t\t},\n\t\t\trequestPath:    \"/api/v0/peer/iface/test123/multiplenew\",\n\t\t\texpectedParam:  \"iface=test123\",\n\t\t\texpectedStatus: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tname:   \"multiple path parameters\",\n\t\t\tmethod: \"GET\",\n\t\t\tsetupFunc: func() *routegroup.Bundle {\n\t\t\t\tmux := http.NewServeMux()\n\t\t\t\tbundle := routegroup.Mount(mux, \"/api\")\n\t\t\t\tbundle.HandleFunc(\"GET /users/{userID}/posts/{postID}\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t\tuserID := r.PathValue(\"userID\")\n\t\t\t\t\tpostID := r.PathValue(\"postID\")\n\t\t\t\t\t_, _ = fmt.Fprintf(w, \"user=%s,post=%s\", userID, postID)\n\t\t\t\t})\n\t\t\t\treturn bundle\n\t\t\t},\n\t\t\trequestPath:    \"/api/users/alice/posts/42\",\n\t\t\texpectedParam:  \"user=alice,post=42\",\n\t\t\texpectedStatus: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tname:   \"path parameters with middleware\",\n\t\t\tmethod: \"GET\",\n\t\t\tsetupFunc: func() *routegroup.Bundle {\n\t\t\t\tmux := http.NewServeMux()\n\t\t\t\tbundle := routegroup.Mount(mux, \"/api\")\n\t\t\t\tbundle.Use(func(next http.Handler) http.Handler {\n\t\t\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t\t\tw.Header().Set(\"X-Middleware\", \"applied\")\n\t\t\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\t\t})\n\t\t\t\t})\n\t\t\t\tbundle.HandleFunc(\"GET /items/{id}\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t\titemID := r.PathValue(\"id\")\n\t\t\t\t\t_, _ = w.Write([]byte(\"item=\" + itemID))\n\t\t\t\t})\n\t\t\t\treturn bundle\n\t\t\t},\n\t\t\trequestPath:    \"/api/items/xyz\",\n\t\t\texpectedParam:  \"item=xyz\",\n\t\t\texpectedStatus: http.StatusOK,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tbundle := tt.setupFunc()\n\t\t\treq := httptest.NewRequest(tt.method, tt.requestPath, http.NoBody)\n\t\t\trr := httptest.NewRecorder()\n\n\t\t\tbundle.ServeHTTP(rr, req)\n\n\t\t\tif rr.Code != tt.expectedStatus {\n\t\t\t\tt.Errorf(\"expected status %d, got %d\", tt.expectedStatus, rr.Code)\n\t\t\t}\n\n\t\t\tif rr.Body.String() != tt.expectedParam {\n\t\t\t\tt.Errorf(\"expected %q, got %q\", tt.expectedParam, rr.Body.String())\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestRemainderWildcards tests the {path...} remainder wildcard feature\nfunc TestRemainderWildcards(t *testing.T) {\n\ttests := []struct {\n\t\tname           string\n\t\tpattern        string\n\t\trequestPath    string\n\t\texpectedParam  string\n\t\texpectedStatus int\n\t}{\n\t\t{\n\t\t\tname:           \"single segment remainder\",\n\t\t\tpattern:        \"GET /files/{path...}\",\n\t\t\trequestPath:    \"/files/document.txt\",\n\t\t\texpectedParam:  \"document.txt\",\n\t\t\texpectedStatus: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tname:           \"multiple segments remainder\",\n\t\t\tpattern:        \"GET /files/{path...}\",\n\t\t\trequestPath:    \"/files/docs/2024/report.pdf\",\n\t\t\texpectedParam:  \"docs/2024/report.pdf\",\n\t\t\texpectedStatus: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tname:           \"remainder with mount\",\n\t\t\tpattern:        \"GET /static/{filepath...}\",\n\t\t\trequestPath:    \"/api/static/css/style.css\",\n\t\t\texpectedParam:  \"css/style.css\",\n\t\t\texpectedStatus: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tname:           \"empty remainder\",\n\t\t\tpattern:        \"GET /files/{path...}\",\n\t\t\trequestPath:    \"/files/\",\n\t\t\texpectedParam:  \"\",\n\t\t\texpectedStatus: http.StatusOK,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tmux := http.NewServeMux()\n\n\t\t\tif tt.name == \"remainder with mount\" {\n\t\t\t\tbundle := routegroup.Mount(mux, \"/api\")\n\t\t\t\tbundle.HandleFunc(tt.pattern, func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t\tfilepathParam := r.PathValue(\"filepath\")\n\t\t\t\t\t_, _ = w.Write([]byte(filepathParam))\n\t\t\t\t})\n\n\t\t\t\treq := httptest.NewRequest(\"GET\", tt.requestPath, http.NoBody)\n\t\t\t\trr := httptest.NewRecorder()\n\t\t\t\tbundle.ServeHTTP(rr, req)\n\n\t\t\t\tif rr.Code != tt.expectedStatus {\n\t\t\t\t\tt.Errorf(\"expected status %d, got %d\", tt.expectedStatus, rr.Code)\n\t\t\t\t}\n\t\t\t\tif rr.Body.String() != tt.expectedParam {\n\t\t\t\t\tt.Errorf(\"expected %q, got %q\", tt.expectedParam, rr.Body.String())\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tbundle := routegroup.New(mux)\n\t\t\t\tbundle.HandleFunc(tt.pattern, func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t\tpath := r.PathValue(\"path\")\n\t\t\t\t\t_, _ = w.Write([]byte(path))\n\t\t\t\t})\n\n\t\t\t\treq := httptest.NewRequest(\"GET\", tt.requestPath, http.NoBody)\n\t\t\t\trr := httptest.NewRecorder()\n\t\t\t\tbundle.ServeHTTP(rr, req)\n\n\t\t\t\tif rr.Code != tt.expectedStatus {\n\t\t\t\t\tt.Errorf(\"expected status %d, got %d\", tt.expectedStatus, rr.Code)\n\t\t\t\t}\n\t\t\t\tif rr.Body.String() != tt.expectedParam {\n\t\t\t\t\tt.Errorf(\"expected %q, got %q\", tt.expectedParam, rr.Body.String())\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestMiddlewareWithContextAndPathParams tests that path params survive middleware context changes\nfunc TestMiddlewareWithContextAndPathParams(t *testing.T) {\n\ttype contextKey string\n\tconst userKey contextKey = \"user\"\n\ttype requestIDKey string\n\tconst reqIDKey requestIDKey = \"request-id\"\n\n\ttests := []struct {\n\t\tname          string\n\t\tmethod        string\n\t\tsetupFunc     func() *routegroup.Bundle\n\t\trequestPath   string\n\t\texpectedParam string\n\t\texpectedUser  string\n\t}{\n\t\t{\n\t\t\tname:   \"WithContext preserves path params\",\n\t\t\tmethod: \"GET\",\n\t\t\tsetupFunc: func() *routegroup.Bundle {\n\t\t\t\tmux := http.NewServeMux()\n\t\t\t\tbundle := routegroup.New(mux)\n\n\t\t\t\t// middleware that adds context value using WithContext\n\t\t\t\tbundle.Use(func(next http.Handler) http.Handler {\n\t\t\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t\t\tctx := context.WithValue(r.Context(), userKey, \"alice\")\n\t\t\t\t\t\tnext.ServeHTTP(w, r.WithContext(ctx))\n\t\t\t\t\t})\n\t\t\t\t})\n\n\t\t\t\tbundle.HandleFunc(\"GET /users/{id}\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t\tuserID := r.PathValue(\"id\")\n\t\t\t\t\tuser := r.Context().Value(userKey).(string)\n\t\t\t\t\t_, _ = fmt.Fprintf(w, \"id=%s,user=%s\", userID, user)\n\t\t\t\t})\n\n\t\t\t\treturn bundle\n\t\t\t},\n\t\t\trequestPath:   \"/users/123\",\n\t\t\texpectedParam: \"id=123,user=alice\",\n\t\t\texpectedUser:  \"alice\",\n\t\t},\n\t\t{\n\t\t\tname:   \"Clone preserves path params\",\n\t\t\tmethod: \"GET\",\n\t\t\tsetupFunc: func() *routegroup.Bundle {\n\t\t\t\tmux := http.NewServeMux()\n\t\t\t\tbundle := routegroup.New(mux)\n\n\t\t\t\t// middleware that clones request with new context\n\t\t\t\tbundle.Use(func(next http.Handler) http.Handler {\n\t\t\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t\t\tctx := context.WithValue(r.Context(), userKey, \"bob\")\n\t\t\t\t\t\tnewReq := r.Clone(ctx)\n\t\t\t\t\t\tnext.ServeHTTP(w, newReq)\n\t\t\t\t\t})\n\t\t\t\t})\n\n\t\t\t\tbundle.HandleFunc(\"GET /items/{itemID}/details\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t\titemID := r.PathValue(\"itemID\")\n\t\t\t\t\tuser := r.Context().Value(userKey).(string)\n\t\t\t\t\t_, _ = fmt.Fprintf(w, \"item=%s,user=%s\", itemID, user)\n\t\t\t\t})\n\n\t\t\t\treturn bundle\n\t\t\t},\n\t\t\trequestPath:   \"/items/xyz/details\",\n\t\t\texpectedParam: \"item=xyz,user=bob\",\n\t\t\texpectedUser:  \"bob\",\n\t\t},\n\t\t{\n\t\t\tname:   \"Multiple middleware with context changes\",\n\t\t\tmethod: \"POST\",\n\t\t\tsetupFunc: func() *routegroup.Bundle {\n\t\t\t\tmux := http.NewServeMux()\n\t\t\t\tbundle := routegroup.New(mux)\n\n\t\t\t\t// first middleware\n\t\t\t\tbundle.Use(func(next http.Handler) http.Handler {\n\t\t\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t\t\tctx := context.WithValue(r.Context(), reqIDKey, \"req-123\")\n\t\t\t\t\t\tnext.ServeHTTP(w, r.WithContext(ctx))\n\t\t\t\t\t})\n\t\t\t\t})\n\n\t\t\t\t// second middleware\n\t\t\t\tbundle.Use(func(next http.Handler) http.Handler {\n\t\t\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t\t\tctx := context.WithValue(r.Context(), userKey, \"charlie\")\n\t\t\t\t\t\tnext.ServeHTTP(w, r.WithContext(ctx))\n\t\t\t\t\t})\n\t\t\t\t})\n\n\t\t\t\tbundle.HandleFunc(\"POST /api/{version}/users/{id}\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t\tversion := r.PathValue(\"version\")\n\t\t\t\t\tuserID := r.PathValue(\"id\")\n\t\t\t\t\tuser := r.Context().Value(userKey).(string)\n\t\t\t\t\treqID := r.Context().Value(reqIDKey).(string)\n\t\t\t\t\t_, _ = fmt.Fprintf(w, \"v=%s,id=%s,user=%s,req=%s\", version, userID, user, reqID)\n\t\t\t\t})\n\n\t\t\t\treturn bundle\n\t\t\t},\n\t\t\trequestPath:   \"/api/v2/users/456\",\n\t\t\texpectedParam: \"v=v2,id=456,user=charlie,req=req-123\",\n\t\t\texpectedUser:  \"charlie\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tbundle := tt.setupFunc()\n\t\t\treq := httptest.NewRequest(tt.method, tt.requestPath, http.NoBody)\n\t\t\trr := httptest.NewRecorder()\n\n\t\t\tbundle.ServeHTTP(rr, req)\n\n\t\t\tif rr.Code != http.StatusOK {\n\t\t\t\tt.Errorf(\"expected status OK, got %d\", rr.Code)\n\t\t\t}\n\n\t\t\tif rr.Body.String() != tt.expectedParam {\n\t\t\t\tt.Errorf(\"expected %q, got %q\", tt.expectedParam, rr.Body.String())\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestURLEncodedPathParams tests that URL-encoded path parameters are properly decoded\nfunc TestURLEncodedPathParams(t *testing.T) {\n\ttests := []struct {\n\t\tname           string\n\t\tpattern        string\n\t\trequestPath    string\n\t\texpectedParam  string\n\t\texpectedStatus int\n\t}{\n\t\t{\n\t\t\tname:           \"space encoded as %20\",\n\t\t\tpattern:        \"GET /users/{name}\",\n\t\t\trequestPath:    \"/users/John%20Doe\",\n\t\t\texpectedParam:  \"John Doe\",\n\t\t\texpectedStatus: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tname:           \"slash encoded as %2F\",\n\t\t\tpattern:        \"GET /files/{filename}\",\n\t\t\trequestPath:    \"/files/folder%2Ffile.txt\",\n\t\t\texpectedParam:  \"folder/file.txt\",\n\t\t\texpectedStatus: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tname:           \"special characters\",\n\t\t\tpattern:        \"GET /search/{query}\",\n\t\t\trequestPath:    \"/search/hello%3Dworld%26foo%3Dbar\",\n\t\t\texpectedParam:  \"hello=world&foo=bar\",\n\t\t\texpectedStatus: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tname:           \"unicode characters\",\n\t\t\tpattern:        \"GET /users/{name}\",\n\t\t\trequestPath:    \"/users/%E4%B8%AD%E6%96%87\",\n\t\t\texpectedParam:  \"中文\",\n\t\t\texpectedStatus: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tname:           \"plus sign handling\",\n\t\t\tpattern:        \"GET /api/{version}\",\n\t\t\trequestPath:    \"/api/v1%2B2\",\n\t\t\texpectedParam:  \"v1+2\",\n\t\t\texpectedStatus: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tname:           \"percent sign itself\",\n\t\t\tpattern:        \"GET /discount/{code}\",\n\t\t\trequestPath:    \"/discount/SAVE%2550\",\n\t\t\texpectedParam:  \"SAVE%50\",\n\t\t\texpectedStatus: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tname:           \"encoded in remainder wildcard\",\n\t\t\tpattern:        \"GET /static/{path...}\",\n\t\t\trequestPath:    \"/static/images%2Flogo%20v2.png\",\n\t\t\texpectedParam:  \"images/logo v2.png\",\n\t\t\texpectedStatus: http.StatusOK,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tmux := http.NewServeMux()\n\t\t\tbundle := routegroup.New(mux)\n\n\t\t\tbundle.HandleFunc(tt.pattern, func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tvar param string\n\t\t\t\tswitch {\n\t\t\t\tcase strings.Contains(tt.pattern, \"{path...}\"):\n\t\t\t\t\tparam = r.PathValue(\"path\")\n\t\t\t\tcase strings.Contains(tt.pattern, \"{name}\"):\n\t\t\t\t\tparam = r.PathValue(\"name\")\n\t\t\t\tcase strings.Contains(tt.pattern, \"{filename}\"):\n\t\t\t\t\tparam = r.PathValue(\"filename\")\n\t\t\t\tcase strings.Contains(tt.pattern, \"{query}\"):\n\t\t\t\t\tparam = r.PathValue(\"query\")\n\t\t\t\tcase strings.Contains(tt.pattern, \"{version}\"):\n\t\t\t\t\tparam = r.PathValue(\"version\")\n\t\t\t\tcase strings.Contains(tt.pattern, \"{code}\"):\n\t\t\t\t\tparam = r.PathValue(\"code\")\n\t\t\t\t}\n\t\t\t\t_, _ = w.Write([]byte(param))\n\t\t\t})\n\n\t\t\treq := httptest.NewRequest(\"GET\", tt.requestPath, http.NoBody)\n\t\t\trr := httptest.NewRecorder()\n\n\t\t\tbundle.ServeHTTP(rr, req)\n\n\t\t\tif rr.Code != tt.expectedStatus {\n\t\t\t\tt.Errorf(\"expected status %d, got %d\", tt.expectedStatus, rr.Code)\n\t\t\t}\n\n\t\t\tif rr.Body.String() != tt.expectedParam {\n\t\t\t\tt.Errorf(\"expected %q, got %q\", tt.expectedParam, rr.Body.String())\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestMethodlessPathParams tests path parameters without HTTP method prefix\nfunc TestMethodlessPathParams(t *testing.T) {\n\tmux := http.NewServeMux()\n\tbundle := routegroup.New(mux)\n\n\t// pattern without method prefix - should work for all methods\n\tbundle.HandleFunc(\"/items/{id}\", func(w http.ResponseWriter, r *http.Request) {\n\t\tid := r.PathValue(\"id\")\n\t\t_, _ = w.Write([]byte(\"item:\" + id))\n\t})\n\n\ttests := []struct {\n\t\tmethod string\n\t\tpath   string\n\t\twant   string\n\t}{\n\t\t{\"GET\", \"/items/123\", \"item:123\"},\n\t\t{\"POST\", \"/items/456\", \"item:456\"},\n\t\t{\"PUT\", \"/items/789\", \"item:789\"},\n\t\t{\"DELETE\", \"/items/abc\", \"item:abc\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.method, func(t *testing.T) {\n\t\t\treq := httptest.NewRequest(tt.method, tt.path, http.NoBody)\n\t\t\trr := httptest.NewRecorder()\n\n\t\t\tbundle.ServeHTTP(rr, req)\n\n\t\t\tif rr.Code != http.StatusOK {\n\t\t\t\tt.Errorf(\"expected status OK, got %d\", rr.Code)\n\t\t\t}\n\t\t\tif rr.Body.String() != tt.want {\n\t\t\t\tt.Errorf(\"expected %q, got %q\", tt.want, rr.Body.String())\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestRootBundlePathParams tests path parameters on root bundle without Mount\nfunc TestRootBundlePathParams(t *testing.T) {\n\tmux := http.NewServeMux()\n\tbundle := routegroup.New(mux)\n\n\tbundle.HandleFunc(\"GET /users/{id}\", func(w http.ResponseWriter, r *http.Request) {\n\t\tid := r.PathValue(\"id\")\n\t\t_, _ = w.Write([]byte(\"user:\" + id))\n\t})\n\n\treq := httptest.NewRequest(\"GET\", \"/users/root123\", http.NoBody)\n\trr := httptest.NewRecorder()\n\n\tbundle.ServeHTTP(rr, req)\n\n\tif rr.Code != http.StatusOK {\n\t\tt.Errorf(\"expected status OK, got %d\", rr.Code)\n\t}\n\tif got := rr.Body.String(); got != \"user:root123\" {\n\t\tt.Errorf(\"expected user:root123, got %q\", got)\n\t}\n}\n\n// TestHEADWithPathParams tests HEAD requests with path parameters\nfunc TestHEADWithPathParams(t *testing.T) {\n\tmux := http.NewServeMux()\n\tbundle := routegroup.New(mux)\n\n\t// GET handler should also handle HEAD\n\tbundle.HandleFunc(\"GET /api/{version}/status\", func(w http.ResponseWriter, r *http.Request) {\n\t\tversion := r.PathValue(\"version\")\n\t\tw.Header().Set(\"X-Version\", version)\n\t\tw.Header().Set(\"Content-Length\", \"10\")\n\t\tif r.Method != \"HEAD\" {\n\t\t\t_, _ = w.Write([]byte(\"status:ok\"))\n\t\t}\n\t})\n\n\t// test HEAD request\n\treq := httptest.NewRequest(\"HEAD\", \"/api/v2/status\", http.NoBody)\n\trr := httptest.NewRecorder()\n\n\tbundle.ServeHTTP(rr, req)\n\n\tif rr.Code != http.StatusOK {\n\t\tt.Errorf(\"expected status OK, got %d\", rr.Code)\n\t}\n\tif got := rr.Header().Get(\"X-Version\"); got != \"v2\" {\n\t\tt.Errorf(\"expected X-Version header v2, got %q\", got)\n\t}\n\tif rr.Body.Len() != 0 {\n\t\tt.Errorf(\"HEAD response should have no body, got %d bytes\", rr.Body.Len())\n\t}\n}\n"
  },
  {
    "path": "routing_test.go",
    "content": "package routegroup_test\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/go-pkgz/routegroup\"\n)\n\nfunc TestHTTPServerWithRoot(t *testing.T) {\n\tgroup := routegroup.New(http.NewServeMux())\n\tgroup.Use(testMiddleware)\n\tgroup.HandleFunc(\"/test\", func(w http.ResponseWriter, _ *http.Request) {\n\t\t_, _ = w.Write([]byte(\"test handler\"))\n\t})\n\tgroup.HandleFunc(\"/\", func(w http.ResponseWriter, _ *http.Request) {\n\t\t_, _ = w.Write([]byte(\"root handler\"))\n\t})\n\ttestServer := httptest.NewServer(group)\n\tdefer testServer.Close()\n\n\tt.Run(\"GET /test\", func(t *testing.T) {\n\t\tresp, err := http.Get(testServer.URL + \"/test\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif string(body) != \"test handler\" {\n\t\t\tt.Errorf(\"Expected body 'test handler', got '%s'\", string(body))\n\t\t}\n\t\tif header := resp.Header.Get(\"X-Test-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-Test-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t})\n\n\tt.Run(\"GET /\", func(t *testing.T) {\n\t\tresp, err := http.Get(testServer.URL + \"/\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif string(body) != \"root handler\" {\n\t\t\tt.Errorf(\"Expected body 'root handler', got '%s'\", string(body))\n\t\t}\n\t\tif header := resp.Header.Get(\"X-Test-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-Test-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t})\n\n\tt.Run(\"/\", func(t *testing.T) {\n\t\tresp, err := http.Get(testServer.URL + \"/\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif string(body) != \"root handler\" {\n\t\t\tt.Errorf(\"Expected body 'root handler', got '%s'\", string(body))\n\t\t}\n\t\tif header := resp.Header.Get(\"X-Test-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-Test-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t})\n\n\tt.Run(\"GET /unknown-path\", func(t *testing.T) {\n\t\tresp, err := http.Get(testServer.URL + \"/unknown-path\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif resp.StatusCode != http.StatusNotFound {\n\t\t\tt.Errorf(\"Expected status code %d, got %d\", http.StatusNotFound, resp.StatusCode)\n\t\t}\n\t\tif string(body) != \"404 page not found\\n\" {\n\t\t\tt.Errorf(\"Expected body '404 page not found\\n', got '%s'\", string(body))\n\t\t}\n\t\tif header := resp.Header.Get(\"X-Test-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-Test-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t})\n}\n\nfunc TestHTTPServerWithRoot122(t *testing.T) {\n\tgroup := routegroup.New(http.NewServeMux())\n\tgroup.Use(testMiddleware)\n\tgroup.HandleFunc(\"GET /test\", func(w http.ResponseWriter, _ *http.Request) {\n\t\t_, _ = w.Write([]byte(\"test handler\"))\n\t})\n\tgroup.HandleFunc(\"GET /\", func(w http.ResponseWriter, _ *http.Request) {\n\t\t_, _ = w.Write([]byte(\"root handler\"))\n\t})\n\ttestServer := httptest.NewServer(group)\n\tdefer testServer.Close()\n\n\tt.Run(\"GET /test\", func(t *testing.T) {\n\t\tresp, err := http.Get(testServer.URL + \"/test\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif string(body) != \"test handler\" {\n\t\t\tt.Errorf(\"Expected body 'test handler', got '%s'\", string(body))\n\t\t}\n\t\tif header := resp.Header.Get(\"X-Test-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-Test-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t})\n\n\tt.Run(\"GET /\", func(t *testing.T) {\n\t\tresp, err := http.Get(testServer.URL + \"/\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif string(body) != \"root handler\" {\n\t\t\tt.Errorf(\"Expected body 'root handler', got '%s'\", string(body))\n\t\t}\n\t\tif header := resp.Header.Get(\"X-Test-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-Test-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t})\n\n\tt.Run(\"POST / wrong method -> 405\", func(t *testing.T) {\n\t\tresp, err := http.Post(testServer.URL+\"/\", \"application/json\", http.NoBody)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif resp.StatusCode != http.StatusMethodNotAllowed {\n\t\t\tt.Errorf(\"Expected status code %d, got %d\", http.StatusMethodNotAllowed, resp.StatusCode)\n\t\t}\n\t\tif string(body) != \"Method Not Allowed\\n\" {\n\t\t\tt.Errorf(\"Expected body 'Method Not Allowed', got '%s'\", string(body))\n\t\t}\n\t\tif allow := resp.Header.Get(\"Allow\"); !strings.Contains(allow, http.MethodGet) {\n\t\t\tt.Errorf(\"expected Allow header to contain GET, got %q\", allow)\n\t\t}\n\t\tif header := resp.Header.Get(\"X-Test-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-Test-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t})\n\n\tt.Run(\"GET /unknown-path\", func(t *testing.T) {\n\t\tresp, err := http.Get(testServer.URL + \"/unknown-path\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif resp.StatusCode != http.StatusNotFound {\n\t\t\tt.Errorf(\"Expected status code %d, got %d\", http.StatusNotFound, resp.StatusCode)\n\t\t}\n\t\tif string(body) != \"404 page not found\\n\" {\n\t\t\tt.Errorf(\"Expected body '404 page not found\\n', got '%s'\", string(body))\n\t\t}\n\t\tif header := resp.Header.Get(\"X-Test-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-Test-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t})\n}\n\nfunc TestRootAndCatchAll(t *testing.T) {\n\tgroup := routegroup.New(http.NewServeMux())\n\tgroup.Use(testMiddleware)\n\tgroup.HandleFunc(\"/\", func(w http.ResponseWriter, _ *http.Request) {\n\t\t_, _ = w.Write([]byte(\"root handler\"))\n\t})\n\tgroup.NotFoundHandler(func(w http.ResponseWriter, _ *http.Request) {\n\t\t_, _ = w.Write([]byte(\"custom not found handler\"))\n\t})\n\n\ttestServer := httptest.NewServer(group)\n\tdefer testServer.Close()\n\n\tt.Run(\"GET /\", func(t *testing.T) {\n\t\tresp, err := http.Get(testServer.URL + \"/\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\tt.Errorf(\"Expected status code %d, got %d\", http.StatusOK, resp.StatusCode)\n\t\t}\n\t\tif string(body) != \"root handler\" {\n\t\t\tt.Errorf(\"Expected body 'root handler', got '%s'\", string(body))\n\t\t}\n\t\tif header := resp.Header.Get(\"X-Test-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-Test-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t})\n\n\tt.Run(\"GET /unknown-path\", func(t *testing.T) {\n\t\tresp, err := http.Get(testServer.URL + \"/unknown-path\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\tt.Errorf(\"Expected status code %d, got %d\", http.StatusOK, resp.StatusCode)\n\t\t}\n\t\tif string(body) != \"custom not found handler\" {\n\t\t\tt.Errorf(\"Expected body 'custom not found handler', got '%s'\", string(body))\n\t\t}\n\t\tif header := resp.Header.Get(\"X-Test-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-Test-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t})\n}\n\nfunc TestHTTPServerMethodAndPathHandling(t *testing.T) {\n\tgroup := routegroup.Mount(http.NewServeMux(), \"/api\")\n\n\tgroup.Use(testMiddleware)\n\n\tgroup.HandleFunc(\"GET /test\", func(w http.ResponseWriter, _ *http.Request) {\n\t\t_, _ = w.Write([]byte(\"GET test method handler\"))\n\t})\n\n\tgroup.HandleFunc(\"/test2\", func(w http.ResponseWriter, _ *http.Request) {\n\t\t_, _ = w.Write([]byte(\"test2 method handler\"))\n\t})\n\n\ttestServer := httptest.NewServer(group)\n\tdefer testServer.Close()\n\n\tt.Run(\"handle with verb\", func(t *testing.T) {\n\t\tresp, err := http.Get(testServer.URL + \"/api/test\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif string(body) != \"GET test method handler\" {\n\t\t\tt.Errorf(\"Expected body 'GET test method handler', got '%s'\", string(body))\n\t\t}\n\t\tif header := resp.Header.Get(\"X-Test-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-Test-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t})\n\n\tt.Run(\"handle without verb\", func(t *testing.T) {\n\t\tresp, err := http.Get(testServer.URL + \"/api/test2\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif string(body) != \"test2 method handler\" {\n\t\t\tt.Errorf(\"Expected body 'test2 method handler', got '%s'\", string(body))\n\t\t}\n\t\tif header := resp.Header.Get(\"X-Test-Middleware\"); header != \"true\" {\n\t\t\tt.Errorf(\"Expected header X-Test-Middleware to be 'true', got '%s'\", header)\n\t\t}\n\t})\n}\n\nfunc TestMethodPatternsWithDifferentMethods(t *testing.T) {\n\tgroup := routegroup.New(http.NewServeMux())\n\tgroup.HandleFunc(\"GET /test\", func(w http.ResponseWriter, _ *http.Request) {\n\t\tif _, err := io.WriteString(w, \"GET handler\"); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t})\n\tgroup.HandleFunc(\"POST /test\", func(w http.ResponseWriter, _ *http.Request) {\n\t\tif _, err := io.WriteString(w, \"POST handler\"); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t})\n\n\ttests := []struct {\n\t\tmethod, path, expected string\n\t\tcode                   int\n\t}{\n\t\t{http.MethodGet, \"/test\", \"GET handler\", http.StatusOK},\n\t\t{http.MethodPost, \"/test\", \"POST handler\", http.StatusOK},\n\t\t{http.MethodPut, \"/test\", \"Method Not Allowed\\n\", http.StatusMethodNotAllowed},\n\t}\n\n\tfor _, tt := range tests {\n\t\trec := httptest.NewRecorder()\n\t\treq, _ := http.NewRequest(tt.method, tt.path, http.NoBody)\n\t\tgroup.ServeHTTP(rec, req)\n\t\tif rec.Code != tt.code {\n\t\t\tt.Errorf(\"got %d, want %d\", rec.Code, tt.code)\n\t\t}\n\t\tif rec.Body.String() != tt.expected {\n\t\t\tt.Errorf(\"got %q, want %q\", rec.Body.String(), tt.expected)\n\t\t}\n\t}\n}\n\nfunc TestHandleTrailingSlash(t *testing.T) {\n\trouter := routegroup.New(http.NewServeMux())\n\n\tt.Run(\"handler for pattern with trailing slash\", func(t *testing.T) {\n\t\trouter.Handle(\"/path/\", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t_, _ = w.Write([]byte(\"handler with trailing slash\"))\n\t\t}))\n\t\trouter.HandleFunc(\"GET /path/sub\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\t_, _ = w.Write([]byte(\"sub handler\"))\n\t\t})\n\n\t\tsrv := httptest.NewServer(router)\n\t\tdefer srv.Close()\n\n\t\ttests := []struct {\n\t\t\tname       string\n\t\t\tmethod     string\n\t\t\tpath       string\n\t\t\twantStatus int\n\t\t\twantBody   string\n\t\t}{\n\t\t\t{\"GET /path/\", http.MethodGet, \"/path/\", http.StatusOK, \"handler with trailing slash\"},\n\t\t\t{\"POST /path/\", http.MethodPost, \"/path/\", http.StatusOK, \"handler with trailing slash\"},\n\t\t\t{\"GET /path/sub\", http.MethodGet, \"/path/sub\", http.StatusOK, \"sub handler\"},                   // more specific route wins\n\t\t\t{\"POST /path/sub\", http.MethodPost, \"/path/sub\", http.StatusOK, \"handler with trailing slash\"}, // falls back to /path/\n\t\t\t{\"GET /path/anything\", http.MethodGet, \"/path/anything\", http.StatusOK, \"handler with trailing slash\"},\n\t\t}\n\n\t\tfor _, tt := range tests {\n\t\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t\treq, err := http.NewRequest(tt.method, srv.URL+tt.path, http.NoBody)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatal(err)\n\t\t\t\t}\n\n\t\t\t\tresp, err := http.DefaultClient.Do(req)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatal(err)\n\t\t\t\t}\n\t\t\t\tdefer resp.Body.Close()\n\n\t\t\t\tif resp.StatusCode != tt.wantStatus {\n\t\t\t\t\tt.Errorf(\"got status %d, want %d\", resp.StatusCode, tt.wantStatus)\n\t\t\t\t}\n\n\t\t\t\tbody, err := io.ReadAll(resp.Body)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatal(err)\n\t\t\t\t}\n\n\t\t\t\tif got := string(body); got != tt.wantBody {\n\t\t\t\t\tt.Errorf(\"got body %q, want %q\", got, tt.wantBody)\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t})\n\n\tt.Run(\"mounted handler with trailing slash\", func(t *testing.T) {\n\t\tapi := router.Mount(\"/api\")\n\t\tapi.Handle(\"/v1/\", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t_, _ = w.Write([]byte(\"api v1\"))\n\t\t}))\n\t\tapi.HandleFunc(\"GET /v1/data\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\t_, _ = w.Write([]byte(\"api data\"))\n\t\t})\n\n\t\tsrv := httptest.NewServer(router)\n\t\tdefer srv.Close()\n\n\t\ttests := []struct {\n\t\t\tname       string\n\t\t\tmethod     string\n\t\t\tpath       string\n\t\t\twantStatus int\n\t\t\twantBody   string\n\t\t}{\n\t\t\t{\"GET /api/v1/\", http.MethodGet, \"/api/v1/\", http.StatusOK, \"api v1\"},\n\t\t\t{\"POST /api/v1/\", http.MethodPost, \"/api/v1/\", http.StatusOK, \"api v1\"},\n\t\t\t{\"GET /api/v1/data\", http.MethodGet, \"/api/v1/data\", http.StatusOK, \"api data\"}, // more specific route wins\n\t\t\t{\"POST /api/v1/data\", http.MethodPost, \"/api/v1/data\", http.StatusOK, \"api v1\"}, // falls back to /v1/\n\t\t\t{\"GET /api/v1/anything\", http.MethodGet, \"/api/v1/anything\", http.StatusOK, \"api v1\"},\n\t\t}\n\n\t\tfor _, tt := range tests {\n\t\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t\treq, err := http.NewRequest(tt.method, srv.URL+tt.path, http.NoBody)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatal(err)\n\t\t\t\t}\n\n\t\t\t\tresp, err := http.DefaultClient.Do(req)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatal(err)\n\t\t\t\t}\n\t\t\t\tdefer resp.Body.Close()\n\n\t\t\t\tif resp.StatusCode != tt.wantStatus {\n\t\t\t\t\tt.Errorf(\"got status %d, want %d\", resp.StatusCode, tt.wantStatus)\n\t\t\t\t}\n\n\t\t\t\tbody, err := io.ReadAll(resp.Body)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatal(err)\n\t\t\t\t}\n\n\t\t\t\tif got := string(body); got != tt.wantBody {\n\t\t\t\t\tt.Errorf(\"got body %q, want %q\", got, tt.wantBody)\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t})\n}\n\nfunc TestInvalidPatterns(t *testing.T) {\n\tgroup := routegroup.New(http.NewServeMux())\n\n\ttests := []struct {\n\t\tname      string\n\t\tpattern   string\n\t\tpath      string // actual URL path to test\n\t\twantPanic bool\n\t}{\n\t\t{\"empty pattern\", \"\", \"/\", true},                                                // ServeMux panics on empty pattern\n\t\t{\"just spaces\", \"  \", \"/\", true},                                                // ServeMux panics on spaces-only pattern\n\t\t{\"spaces in path\", \"GET /path%20with%20spaces\", \"/path%20with%20spaces\", false}, // encoded spaces work\n\t\t{\"only method\", \"GET\", \"/\", true},                                               // ServeMux panics on invalid pattern\n\t\t{\"just one slash\", \"/\", \"/\", false},                                             // root path works\n\t\t{\"missing slash\", \"GET /path\", \"/path\", false},                                  // normal pattern\n\t\t{\"double slashes\", \"GET //path\", \"/\", true},                                     // ServeMux panics on unclean paths\n\t\t{\"path without slash\", \"path\", \"/\", true},                                       // must start with /\n\t\t{\"method path without slash\", \"GET path\", \"/\", true},                            // must start with /\n\t\t{\"trailing slash\", \"GET /path/\", \"/path/\", false},                               // trailing slash is ok\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\thandler := func(w http.ResponseWriter, _ *http.Request) {\n\t\t\t\t_, _ = w.Write([]byte(\"handler\"))\n\t\t\t}\n\n\t\t\tif tt.wantPanic {\n\t\t\t\tdefer func() {\n\t\t\t\t\tif r := recover(); r == nil {\n\t\t\t\t\t\tt.Error(\"expected panic but got none\")\n\t\t\t\t\t}\n\t\t\t\t}()\n\t\t\t\tgroup.HandleFunc(tt.pattern, handler)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tgroup.HandleFunc(tt.pattern, handler)\n\t\t\trec := httptest.NewRecorder()\n\t\t\treq := httptest.NewRequest(http.MethodGet, tt.path, http.NoBody)\n\t\t\tgroup.ServeHTTP(rec, req)\n\n\t\t\tif rec.Code == 0 {\n\t\t\t\tt.Error(\"no response code set\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestHandleRoot(t *testing.T) {\n\t// create client that doesn't follow redirects\n\tclient := &http.Client{\n\t\tCheckRedirect: func(req *http.Request, via []*http.Request) error {\n\t\t\treturn http.ErrUseLastResponse // don't follow redirects\n\t\t},\n\t}\n\n\tt.Run(\"HandleRoot with middleware\", func(t *testing.T) {\n\t\tgroup := routegroup.New(http.NewServeMux())\n\t\tgroup.Mount(\"/api\").Route(func(apiGroup *routegroup.Bundle) {\n\t\t\tapiGroup.Use(func(next http.Handler) http.Handler {\n\t\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t\tw.Header().Set(\"X-Middleware\", \"applied\")\n\t\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\t})\n\t\t\t})\n\t\t\tapiGroup.HandleRoot(\"GET\", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif _, err := w.Write([]byte(\"api root\")); err != nil {\n\t\t\t\t\tt.Fatalf(\"failed to write response: %v\", err)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tapiGroup.HandleFunc(\"GET /test\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif _, err := w.Write([]byte(\"test\")); err != nil {\n\t\t\t\t\tt.Fatalf(\"failed to write response: %v\", err)\n\t\t\t\t}\n\t\t\t})\n\t\t})\n\t\tgroup.Mount(\"/api-2\").Route(func(apiGroup *routegroup.Bundle) {\n\t\t\tapiGroup.Use(func(next http.Handler) http.Handler {\n\t\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t\tw.Header().Set(\"X-Middleware\", \"applied\")\n\t\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\t})\n\t\t\t})\n\t\t\tapiGroup.HandleRootFunc(\"GET\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif _, err := w.Write([]byte(\"api root\")); err != nil {\n\t\t\t\t\tt.Fatalf(\"failed to write response: %v\", err)\n\t\t\t\t}\n\t\t\t})\n\t\t\tapiGroup.HandleFunc(\"GET /test\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif _, err := w.Write([]byte(\"test\")); err != nil {\n\t\t\t\t\tt.Fatalf(\"failed to write response: %v\", err)\n\t\t\t\t}\n\t\t\t})\n\t\t})\n\n\t\tts := httptest.NewServer(group)\n\t\tdefer ts.Close()\n\n\t\tapis := []string{\"/api\", \"/api-2\"}\n\t\tfor _, api := range apis {\n\t\t\t// test direct access to registered root /api - should NOT redirect and middleware should be applied\n\t\t\tresp, err := client.Get(ts.URL + api)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"failed to make request: %v\", err)\n\t\t\t}\n\t\t\tif resp.StatusCode != http.StatusOK {\n\t\t\t\tt.Errorf(\"expected status 200, got %d\", resp.StatusCode)\n\t\t\t}\n\t\t\tif resp.Header.Get(\"X-Middleware\") != \"applied\" {\n\t\t\t\tt.Errorf(\"middleware not applied\")\n\t\t\t}\n\t\t\tbody, err := io.ReadAll(resp.Body)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"failed to read response body: %v\", err)\n\t\t\t}\n\t\t\tif string(body) != \"api root\" {\n\t\t\t\tt.Errorf(\"expected 'api root', got '%s'\", body)\n\t\t\t}\n\t\t\tif closeErr := resp.Body.Close(); closeErr != nil {\n\t\t\t\tt.Errorf(\"failed to close response body: %v\", closeErr)\n\t\t\t}\n\n\t\t\t// test access to /api/test\n\t\t\tresp, err = client.Get(ts.URL + api + \"/test\")\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"failed to make request: %v\", err)\n\t\t\t}\n\t\t\tbody, err = io.ReadAll(resp.Body)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"failed to read response body: %v\", err)\n\t\t\t}\n\t\t\tif resp.StatusCode != http.StatusOK {\n\t\t\t\tt.Errorf(\"expected status 200, got %d\", resp.StatusCode)\n\t\t\t}\n\t\t\tif string(body) != \"test\" {\n\t\t\t\tt.Errorf(\"expected 'test', got '%s'\", body)\n\t\t\t}\n\t\t\tif closeErr := resp.Body.Close(); closeErr != nil {\n\t\t\t\tt.Errorf(\"failed to close response body: %v\", closeErr)\n\t\t\t}\n\n\t\t\t// test POST request to /api\n\t\t\treq, err := http.NewRequest(http.MethodPost, ts.URL+api, http.NoBody)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"failed to create request: %v\", err)\n\t\t\t}\n\t\t\tresp, err = client.Do(req)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"failed to make request: %v\", err)\n\t\t\t}\n\t\t\tif resp.StatusCode != http.StatusMethodNotAllowed {\n\t\t\t\tt.Errorf(\"expected status 405, got %d\", resp.StatusCode)\n\t\t\t}\n\t\t\tif allow := resp.Header.Get(\"Allow\"); !strings.Contains(allow, http.MethodGet) {\n\t\t\t\tt.Errorf(\"expected Allow header to contain GET, got %q\", allow)\n\t\t\t}\n\t\t\tif closeErr := resp.Body.Close(); closeErr != nil {\n\t\t\t\tt.Errorf(\"failed to close response body: %v\", closeErr)\n\t\t\t}\n\t\t}\n\t})\n\n\tt.Run(\"HandleRoot without method\", func(t *testing.T) {\n\t\tgroup := routegroup.New(http.NewServeMux())\n\t\tgroup.Mount(\"/data\").Route(func(dataGroup *routegroup.Bundle) {\n\t\t\tdataGroup.Use(func(next http.Handler) http.Handler {\n\t\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t\tw.Header().Set(\"X-Middleware\", \"applied\")\n\t\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\t})\n\t\t\t})\n\t\t\t// register without specifying a method (empty string)\n\t\t\tdataGroup.HandleRoot(\"\", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif _, err := w.Write([]byte(\"data root\")); err != nil {\n\t\t\t\t\tt.Fatalf(\"failed to write response: %v\", err)\n\t\t\t\t}\n\t\t\t}))\n\t\t})\n\t\tgroup.Mount(\"/data-2\").Route(func(dataGroup *routegroup.Bundle) {\n\t\t\tdataGroup.Use(func(next http.Handler) http.Handler {\n\t\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t\tw.Header().Set(\"X-Middleware\", \"applied\")\n\t\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\t})\n\t\t\t})\n\t\t\t// register without specifying a method (empty string)\n\t\t\tdataGroup.HandleRootFunc(\"\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif _, err := w.Write([]byte(\"data root\")); err != nil {\n\t\t\t\t\tt.Fatalf(\"failed to write response: %v\", err)\n\t\t\t\t}\n\t\t\t})\n\t\t})\n\n\t\tts := httptest.NewServer(group)\n\t\tdefer ts.Close()\n\n\t\tpaths := []string{\"/data\", \"/data-2\"}\n\n\t\tfor _, path := range paths {\n\t\t\t// test GET request\n\t\t\tresp, err := client.Get(ts.URL + path)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"failed to make request: %v\", err)\n\t\t\t}\n\t\t\tif resp.StatusCode != http.StatusOK {\n\t\t\t\tt.Errorf(\"expected status 200, got %d\", resp.StatusCode)\n\t\t\t}\n\t\t\tif resp.Header.Get(\"X-Middleware\") != \"applied\" {\n\t\t\t\tt.Errorf(\"middleware not applied\")\n\t\t\t}\n\t\t\tbody, err := io.ReadAll(resp.Body)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"failed to read response body: %v\", err)\n\t\t\t}\n\t\t\tif string(body) != \"data root\" {\n\t\t\t\tt.Errorf(\"expected 'data root', got '%s'\", body)\n\t\t\t}\n\t\t\tif closeErr := resp.Body.Close(); closeErr != nil {\n\t\t\t\tt.Errorf(\"failed to close response body: %v\", closeErr)\n\t\t\t}\n\n\t\t\t// test POST request - should also work since no method was specified\n\t\t\treq, err := http.NewRequest(http.MethodPost, ts.URL+path, http.NoBody)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"failed to create request: %v\", err)\n\t\t\t}\n\t\t\tresp, err = client.Do(req)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"failed to make request: %v\", err)\n\t\t\t}\n\t\t\tif resp.StatusCode != http.StatusOK {\n\t\t\t\tt.Errorf(\"expected status 200, got %d\", resp.StatusCode)\n\t\t\t}\n\t\t\tif resp.Header.Get(\"X-Middleware\") != \"applied\" {\n\t\t\t\tt.Errorf(\"middleware not applied\")\n\t\t\t}\n\t\t\tbody, err = io.ReadAll(resp.Body)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"failed to read response body: %v\", err)\n\t\t\t}\n\t\t\tif string(body) != \"data root\" {\n\t\t\t\tt.Errorf(\"expected 'data root', got '%s'\", body)\n\t\t\t}\n\t\t\tif closeErr := resp.Body.Close(); closeErr != nil {\n\t\t\t\tt.Errorf(\"failed to close response body: %v\", closeErr)\n\t\t\t}\n\t\t}\n\t})\n\n\tt.Run(\"HandleRoot with empty base path\", func(t *testing.T) {\n\t\t// create a group with empty base path\n\t\tgroup := routegroup.New(http.NewServeMux())\n\t\tgroup.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tw.Header().Set(\"X-Middleware\", \"applied\")\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t})\n\n\t\t// handle the root path (empty base path)\n\t\tgroup.HandleRoot(\"GET\", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tif _, err := w.Write([]byte(\"root\")); err != nil {\n\t\t\t\tt.Fatalf(\"failed to write response: %v\", err)\n\t\t\t}\n\t\t}))\n\n\t\tts := httptest.NewServer(group)\n\t\tdefer ts.Close()\n\n\t\t// test GET request to root\n\t\tresp, err := client.Get(ts.URL + \"/\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to make request: %v\", err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\tt.Errorf(\"expected status 200, got %d\", resp.StatusCode)\n\t\t}\n\t\tif resp.Header.Get(\"X-Middleware\") != \"applied\" {\n\t\t\tt.Errorf(\"middleware not applied\")\n\t\t}\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to read response body: %v\", err)\n\t\t}\n\t\tif string(body) != \"root\" {\n\t\t\tt.Errorf(\"expected 'root', got '%s'\", body)\n\t\t}\n\t})\n\n\tt.Run(\"HandleRootFunc with empty base path\", func(t *testing.T) {\n\t\t// create a group with empty base path\n\t\tgroup := routegroup.New(http.NewServeMux())\n\t\tgroup.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tw.Header().Set(\"X-Middleware\", \"applied\")\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t})\n\n\t\t// handle the root path (empty base path)\n\t\tgroup.HandleRootFunc(\"GET\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\tif _, err := w.Write([]byte(\"root\")); err != nil {\n\t\t\t\tt.Fatalf(\"failed to write response: %v\", err)\n\t\t\t}\n\t\t})\n\n\t\tts := httptest.NewServer(group)\n\t\tdefer ts.Close()\n\n\t\t// test GET request to root\n\t\tresp, err := client.Get(ts.URL + \"/\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to make request: %v\", err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\tt.Errorf(\"expected status 200, got %d\", resp.StatusCode)\n\t\t}\n\t\tif resp.Header.Get(\"X-Middleware\") != \"applied\" {\n\t\t\tt.Errorf(\"middleware not applied\")\n\t\t}\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to read response body: %v\", err)\n\t\t}\n\t\tif string(body) != \"root\" {\n\t\t\tt.Errorf(\"expected 'root', got '%s'\", body)\n\t\t}\n\t})\n\n\tt.Run(\"handle with trailing slash\", func(t *testing.T) {\n\t\tgroup := routegroup.New(http.NewServeMux())\n\t\tapiGroup := group.Mount(\"/api\")\n\t\tapiGroup.HandleFunc(\"GET /\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\tif _, err := w.Write([]byte(\"api root\")); err != nil {\n\t\t\t\tt.Fatalf(\"failed to write response: %v\", err)\n\t\t\t}\n\t\t})\n\n\t\tts := httptest.NewServer(group)\n\t\tdefer ts.Close()\n\n\t\tresp, err := client.Get(ts.URL + \"/api\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to make request: %v\", err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\t// verify trailing slash approach causes redirect\n\t\tif resp.StatusCode != http.StatusMovedPermanently {\n\t\t\tt.Errorf(\"expected redirect status 301, got %d\", resp.StatusCode)\n\t\t}\n\n\t\tlocation := resp.Header.Get(\"Location\")\n\t\tif location != \"/api/\" {\n\t\t\tt.Errorf(\"expected redirect to '/api/', got '%s'\", location)\n\t\t}\n\t})\n}\n\nfunc ExampleNew() {\n\tgroup := routegroup.New(http.NewServeMux())\n\n\t// apply middleware to the group\n\tgroup.Use(func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tw.Header().Add(\"X-Mounted-Middleware\", \"true\")\n\t\t\tnext.ServeHTTP(w, r)\n\t\t})\n\t})\n\n\t// add test handlers\n\tgroup.HandleFunc(\"GET /test\", func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t})\n\tgroup.HandleFunc(\"POST /test2\", func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t})\n\n\t// start the server\n\tif err := http.ListenAndServe(\":8080\", group); err != nil {\n\t\tpanic(err)\n\t}\n}\n\nfunc ExampleMount() {\n\tgroup := routegroup.Mount(http.NewServeMux(), \"/api\")\n\n\t// apply middleware to the group\n\tgroup.Use(func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tw.Header().Add(\"X-Test-Middleware\", \"true\")\n\t\t\tnext.ServeHTTP(w, r)\n\t\t})\n\t})\n\n\t// add test handlers\n\tgroup.HandleFunc(\"GET /test\", func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t})\n\tgroup.HandleFunc(\"POST /test2\", func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t})\n\n\t// start the server\n\tif err := http.ListenAndServe(\":8080\", group); err != nil {\n\t\tpanic(err)\n\t}\n}\n\nfunc ExampleBundle_Route() {\n\tgroup := routegroup.New(http.NewServeMux())\n\n\t// configure the group using Set\n\tgroup.Route(func(g *routegroup.Bundle) {\n\t\t// apply middleware to the group\n\t\tg.Use(func(next http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tw.Header().Add(\"X-Test-Middleware\", \"true\")\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t})\n\t\t})\n\t\t// add test handlers\n\t\tg.HandleFunc(\"GET /test\", func(w http.ResponseWriter, _ *http.Request) {\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t})\n\t\tg.HandleFunc(\"POST /test2\", func(w http.ResponseWriter, _ *http.Request) {\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t})\n\t})\n\n\t// start the server\n\tif err := http.ListenAndServe(\":8080\", group); err != nil {\n\t\tpanic(err)\n\t}\n}\n\n// This example shows how to use HandleRoot to handle the root path of a mounted group without trailing slash\nfunc ExampleBundle_HandleRoot() {\n\tgroup := routegroup.New(http.NewServeMux())\n\n\t// create API group\n\tapiGroup := group.Mount(\"/api\")\n\n\t// apply middleware\n\tapiGroup.Use(func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tw.Header().Set(\"X-API\", \"true\")\n\t\t\tnext.ServeHTTP(w, r)\n\t\t})\n\t})\n\n\t// handle root path (responds to /api without redirect)\n\tapiGroup.HandleRoot(\"GET\", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tfmt.Fprintf(w, \"API root\")\n\t}))\n\n\t// regular routes\n\tapiGroup.HandleFunc(\"GET /users\", func(w http.ResponseWriter, r *http.Request) {\n\t\tfmt.Fprintf(w, \"List of users\")\n\t})\n}\n\n// TestPathParametersWithMount tests path parameter extraction with mounted groups (issue #22)\n"
  }
]