github.com/IRelaxxx/servefiles/v3@v3.4.6/assets_test.go (about)

     1  // MIT License
     2  //
     3  // Copyright (c) 2016 Rick Beton
     4  //
     5  // Permission is hereby granted, free of charge, to any person obtaining a copy
     6  // of this software and associated documentation files (the "Software"), to deal
     7  // in the Software without restriction, including without limitation the rights
     8  // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
     9  // copies of the Software, and to permit persons to whom the Software is
    10  // furnished to do so, subject to the following conditions:
    11  //
    12  // The above copyright notice and this permission notice shall be included in all
    13  // copies or substantial portions of the Software.
    14  //
    15  // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    16  // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    17  // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    18  // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    19  // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    20  // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    21  // SOFTWARE.
    22  
    23  package servefiles
    24  
    25  import (
    26  	"fmt"
    27  	"log"
    28  	"net/http"
    29  	"net/http/httptest"
    30  	. "net/url"
    31  	"os"
    32  	"reflect"
    33  	"strings"
    34  	"testing"
    35  	"time"
    36  
    37  	"github.com/spf13/afero"
    38  )
    39  
    40  var emptyStrings []string
    41  
    42  func mustChdir(dir string) {
    43  	err := os.Chdir(dir)
    44  	if err != nil {
    45  		panic(err)
    46  	}
    47  }
    48  
    49  func init() {
    50  	mustChdir("test")
    51  }
    52  
    53  func TestChooseResourceSimpleNoGzip(t *testing.T) {
    54  	cases := []struct {
    55  		n                       int
    56  		maxAge                  time.Duration
    57  		url, path, cacheControl string
    58  	}{
    59  		{0, 1, "http://localhost:8001/img/sort_asc.png", "assets/img/sort_asc.png", "public, maxAge=1"},
    60  		{0, 3671, "http://localhost:8001/img/sort_asc.png", "assets/img/sort_asc.png", "public, maxAge=3671"},
    61  		{3, 3671, "http://localhost:8001/x/y/z/img/sort_asc.png", "assets/img/sort_asc.png", "public, maxAge=3671"},
    62  	}
    63  
    64  	for i, test := range cases {
    65  		etag := etagFor(test.path)
    66  		url := mustUrl(test.url)
    67  		request := &http.Request{Method: "GET", URL: url}
    68  		a := NewAssetHandler("./assets/").StripOff(test.n).WithMaxAge(test.maxAge * time.Second)
    69  		w := httptest.NewRecorder()
    70  
    71  		a.ServeHTTP(w, request)
    72  
    73  		isEqual(t, w.Code, 200, i)
    74  		//isEqual(t, message, "", test.path)
    75  		isEqual(t, len(w.Header()["Expires"]), 1, i)
    76  		isGte(t, len(w.Header()["Expires"][0]), 25, i)
    77  		//fmt.Println(headers["Expires"])
    78  		isEqual(t, w.Header()["Cache-Control"], []string{test.cacheControl}, i)
    79  		isEqual(t, w.Header()["Etag"], []string{etag}, i)
    80  		isEqual(t, w.Body.Len(), 160, i)
    81  	}
    82  }
    83  
    84  func TestChooseResourceSimpleNonExistent(t *testing.T) {
    85  	cases := []struct {
    86  		n      int
    87  		maxAge time.Duration
    88  		url    string
    89  	}{
    90  		{0, time.Second, "http://localhost:8001/img/nonexisting.png"},
    91  		{1, time.Second, "http://localhost:8001/a/img/nonexisting.png"},
    92  		{2, time.Second, "http://localhost:8001/a/b/img/nonexisting.png"},
    93  	}
    94  
    95  	for i, test := range cases {
    96  		url := mustUrl(test.url)
    97  		request := &http.Request{Method: "GET", URL: url}
    98  		a := NewAssetHandler("./assets/").StripOff(test.n).WithMaxAge(test.maxAge)
    99  		w := httptest.NewRecorder()
   100  
   101  		a.ServeHTTP(w, request)
   102  
   103  		isEqual(t, w.Code, 404, i)
   104  		//t.Logf("header %v", w.Header())
   105  		isGte(t, len(w.Header()), 4, i)
   106  		isEqual(t, w.Header().Get("Content-Type"), "text/plain; charset=utf-8", i)
   107  		isEqual(t, w.Header().Get("Cache-Control"), "public, maxAge=1", i)
   108  		isGte(t, len(w.Header().Get("Expires")), 25, i)
   109  	}
   110  }
   111  
   112  func TestServeHTTP200WithGzipAndGzipWithAcceptHeader(t *testing.T) {
   113  	cases := []struct {
   114  		n                                       int
   115  		maxAge                                  time.Duration
   116  		url, mime, encoding, path, cacheControl string
   117  	}{
   118  		{0, 1, "http://localhost:8001/css/style1.css", "text/css; charset=utf-8", "xx, gzip, zzz", "assets/css/style1.css.gz", "public, maxAge=1"},
   119  		{2, 1, "http://localhost:8001/a/b/css/style1.css", "text/css; charset=utf-8", "xx, gzip, zzz", "assets/css/style1.css.gz", "public, maxAge=1"},
   120  		{0, 1, "http://localhost:8001/js/script1.js", "text/javascript; charset=utf-8", "xx, gzip, zzz", "assets/js/script1.js.gz", "public, maxAge=1"},
   121  		{2, 1, "http://localhost:8001/a/b/js/script1.js", "text/javascript; charset=utf-8", "xx, gzip, zzz", "assets/js/script1.js.gz", "public, maxAge=1"},
   122  	}
   123  
   124  	for _, test := range cases {
   125  		etag := etagFor(test.path)
   126  		url := mustUrl(test.url)
   127  		header := newHeader("Accept-Encoding", test.encoding)
   128  		request := &http.Request{Method: "GET", URL: url, Header: header}
   129  		a := NewAssetHandler("./assets/").StripOff(test.n).WithMaxAge(test.maxAge * time.Second)
   130  		w := httptest.NewRecorder()
   131  
   132  		a.ServeHTTP(w, request)
   133  
   134  		isEqual(t, w.Code, 200, test.path)
   135  		headers := w.Header()
   136  		//t.Logf("%+v\n", headers)
   137  		isGte(t, len(headers), 7, test.path)
   138  		isEqual(t, headers["Cache-Control"], []string{test.cacheControl}, test.path)
   139  		isEqual(t, headers["Content-Type"], []string{test.mime}, test.path)
   140  		isEqual(t, headers["X-Content-Type-Options"], []string{"nosniff"}, test.path)
   141  		isEqual(t, headers["Content-Encoding"], []string{"gzip"}, test.path)
   142  		isEqual(t, headers["Vary"], []string{"Accept-Encoding"}, test.path)
   143  		isEqual(t, headers["Etag"], []string{"W/" + etag}, test.path)
   144  		isEqual(t, len(headers["Expires"]), 1, test.path)
   145  		isGte(t, len(headers["Expires"][0]), 25, test.path)
   146  	}
   147  }
   148  
   149  func TestServeHTTP200WithBrAndBrWithAcceptHeader(t *testing.T) {
   150  	cases := []struct {
   151  		n                                       int
   152  		maxAge                                  time.Duration
   153  		url, mime, encoding, path, cacheControl string
   154  	}{
   155  		{0, 1, "http://localhost:8001/css/style1.css", "text/css; charset=utf-8", "br, gzip, zzz", "assets/css/style1.css.br", "public, maxAge=1"},
   156  		{2, 1, "http://localhost:8001/a/b/css/style1.css", "text/css; charset=utf-8", "br, gzip, zzz", "assets/css/style1.css.br", "public, maxAge=1"},
   157  		{0, 1, "http://localhost:8001/js/script1.js", "text/javascript; charset=utf-8", "br, gzip, zzz", "assets/js/script1.js.br", "public, maxAge=1"},
   158  		{2, 1, "http://localhost:8001/a/b/js/script1.js", "text/javascript; charset=utf-8", "br, gzip, zzz", "assets/js/script1.js.br", "public, maxAge=1"},
   159  	}
   160  
   161  	for _, test := range cases {
   162  		etag := etagFor(test.path)
   163  		url := mustUrl(test.url)
   164  		header := newHeader("Accept-Encoding", test.encoding)
   165  		request := &http.Request{Method: "GET", URL: url, Header: header}
   166  		a := NewAssetHandler("./assets/").StripOff(test.n).WithMaxAge(test.maxAge * time.Second)
   167  		w := httptest.NewRecorder()
   168  
   169  		a.ServeHTTP(w, request)
   170  
   171  		isEqual(t, w.Code, 200, test.path)
   172  		headers := w.Header()
   173  		//t.Logf("%+v\n", headers)
   174  		isGte(t, len(headers), 7, test.path)
   175  		isEqual(t, headers["Cache-Control"], []string{test.cacheControl}, test.path)
   176  		isEqual(t, headers["Content-Type"], []string{test.mime}, test.path)
   177  		isEqual(t, headers["X-Content-Type-Options"], []string{"nosniff"}, test.path)
   178  		isEqual(t, headers["Content-Encoding"], []string{"br"}, test.path)
   179  		isEqual(t, headers["Vary"], []string{"Accept-Encoding"}, test.path)
   180  		isEqual(t, headers["Etag"], []string{"W/" + etag}, test.path)
   181  		isEqual(t, len(headers["Expires"]), 1, test.path)
   182  		isGte(t, len(headers["Expires"][0]), 25, test.path)
   183  	}
   184  }
   185  
   186  func TestServeHTTP200WithGzipButNoAcceptHeader(t *testing.T) {
   187  	cases := []struct {
   188  		n                                       int
   189  		maxAge                                  time.Duration
   190  		url, mime, encoding, path, cacheControl string
   191  	}{
   192  		{0, 1, "http://localhost:8001/css/style1.css", "text/css; charset=utf-8", "xx, yy, zzz", "assets/css/style1.css", "public, maxAge=1"},
   193  		{2, 2, "http://localhost:8001/a/b/css/style1.css", "text/css; charset=utf-8", "xx, yy, zzz", "assets/css/style1.css", "public, maxAge=2"},
   194  		{0, 3, "http://localhost:8001/js/script1.js", "text/javascript; charset=utf-8", "xx, yy, zzz", "assets/js/script1.js", "public, maxAge=3"},
   195  		{2, 4, "http://localhost:8001/a/b/js/script1.js", "text/javascript; charset=utf-8", "xx, yy, zzz", "assets/js/script1.js", "public, maxAge=4"},
   196  	}
   197  
   198  	for _, test := range cases {
   199  		etag := etagFor(test.path)
   200  		url := mustUrl(test.url)
   201  		header := newHeader("Accept-Encoding", test.encoding)
   202  		request := &http.Request{Method: "GET", URL: url, Header: header}
   203  		a := NewAssetHandler("./assets/").StripOff(test.n).WithMaxAge(test.maxAge * time.Second)
   204  		w := httptest.NewRecorder()
   205  
   206  		a.ServeHTTP(w, request)
   207  
   208  		isEqual(t, w.Code, 200, test.path)
   209  		headers := w.Header()
   210  		//t.Logf("%+v\n", headers)
   211  		isGte(t, len(headers), 6, test.path)
   212  		isEqual(t, headers["Cache-Control"], []string{test.cacheControl}, test.path)
   213  		isEqual(t, headers["Content-Type"], []string{test.mime}, test.path)
   214  		isEqual(t, headers["Content-Encoding"], emptyStrings, test.path)
   215  		isEqual(t, headers["Vary"], emptyStrings, test.path)
   216  		isEqual(t, headers["Etag"], []string{etag}, test.path)
   217  		isEqual(t, len(headers["Expires"]), 1, test.path)
   218  		isGte(t, len(headers["Expires"][0]), 25, test.path)
   219  	}
   220  }
   221  
   222  func TestServeHTTP200WithGzipAcceptHeaderButNoGzippedFile(t *testing.T) {
   223  	cases := []struct {
   224  		n                                       int
   225  		maxAge                                  time.Duration
   226  		url, mime, encoding, path, cacheControl string
   227  	}{
   228  		{0, 1, "http://localhost:8001/css/style2.css", "text/css; charset=utf-8", "xx, gzip, zzz", "assets/css/style2.css", "public, maxAge=1"},
   229  		{0, 1, "http://localhost:8001/css/style2.css", "text/css; charset=utf-8", "br, gzip, zzz", "assets/css/style2.css", "public, maxAge=1"},
   230  		{2, 2, "http://localhost:8001/a/b/css/style2.css", "text/css; charset=utf-8", "xx, gzip, zzz", "assets/css/style2.css", "public, maxAge=2"},
   231  		{2, 2, "http://localhost:8001/a/b/css/style2.css", "text/css; charset=utf-8", "br, gzip, zzz", "assets/css/style2.css", "public, maxAge=2"},
   232  		{0, 3, "http://localhost:8001/js/script2.js", "text/javascript; charset=utf-8", "xx, gzip, zzz", "assets/js/script2.js", "public, maxAge=3"},
   233  		{0, 3, "http://localhost:8001/js/script2.js", "text/javascript; charset=utf-8", "br, gzip, zzz", "assets/js/script2.js", "public, maxAge=3"},
   234  		{2, 4, "http://localhost:8001/a/b/js/script2.js", "text/javascript; charset=utf-8", "xx, gzip, zzz", "assets/js/script2.js", "public, maxAge=4"},
   235  		{2, 4, "http://localhost:8001/a/b/js/script2.js", "text/javascript; charset=utf-8", "br, gzip, zzz", "assets/js/script2.js", "public, maxAge=4"},
   236  		{0, 5, "http://localhost:8001/img/sort_asc.png", "image/png", "xx, gzip, zzz", "assets/img/sort_asc.png", "public, maxAge=5"},
   237  		{0, 5, "http://localhost:8001/img/sort_asc.png", "image/png", "br, gzip, zzz", "assets/img/sort_asc.png", "public, maxAge=5"},
   238  		{2, 6, "http://localhost:8001/a/b/img/sort_asc.png", "image/png", "xx, gzip, zzz", "assets/img/sort_asc.png", "public, maxAge=6"},
   239  		{2, 6, "http://localhost:8001/a/b/img/sort_asc.png", "image/png", "br, gzip, zzz", "assets/img/sort_asc.png", "public, maxAge=6"},
   240  	}
   241  
   242  	for _, test := range cases {
   243  		etag := etagFor(test.path)
   244  		url := mustUrl(test.url)
   245  		header := newHeader("Accept-Encoding", test.encoding)
   246  		request := &http.Request{Method: "GET", URL: url, Header: header}
   247  		a := NewAssetHandler("./assets/").StripOff(test.n).WithMaxAge(test.maxAge * time.Second)
   248  		w := httptest.NewRecorder()
   249  
   250  		a.ServeHTTP(w, request)
   251  
   252  		isEqual(t, w.Code, 200, test.path)
   253  		headers := w.Header()
   254  		//t.Logf("%+v\n", headers)
   255  		isGte(t, len(headers), 6, test.path)
   256  		isEqual(t, headers["Cache-Control"], []string{test.cacheControl}, test.path)
   257  		isEqual(t, headers["Content-Type"], []string{test.mime}, test.path)
   258  		isEqual(t, headers["Content-Encoding"], emptyStrings, test.path)
   259  		isEqual(t, headers["Vary"], emptyStrings, test.path)
   260  		isEqual(t, headers["Etag"], []string{etag}, test.path)
   261  		isEqual(t, len(headers["Expires"]), 1, test.path)
   262  		isGte(t, len(headers["Expires"][0]), 25, test.path)
   263  	}
   264  }
   265  
   266  //-------------------------------------------------------------------------------------------------
   267  
   268  type h404 struct{}
   269  
   270  func (h *h404) ServeHTTP(w http.ResponseWriter, r *http.Request) {
   271  	w.Header().Set("Content-Type", "text/html")
   272  	w.WriteHeader(404)
   273  	w.Write([]byte("<html>foo</html>"))
   274  }
   275  
   276  func Test404Handler(t *testing.T) {
   277  	cases := []struct {
   278  		path, conType, response string
   279  		notFound                http.Handler
   280  	}{
   281  		{"/img/nonexisting.png", "text/plain; charset=utf-8", "404 Not found\n", nil},
   282  		{"/img/nonexisting.png", "text/html", "<html>foo</html>", &h404{}},
   283  	}
   284  
   285  	for i, test := range cases {
   286  		url := mustUrl("http://localhost:8001" + test.path)
   287  		request := &http.Request{Method: "GET", URL: url}
   288  		a := NewAssetHandler("./assets/").WithNotFound(test.notFound)
   289  		isEqual(t, a.NotFound, test.notFound, i)
   290  		w := httptest.NewRecorder()
   291  
   292  		a.ServeHTTP(w, request)
   293  
   294  		isEqual(t, w.Code, 404, i)
   295  		isEqual(t, w.Header().Get("Content-Type"), test.conType, i)
   296  		isEqual(t, w.Body.String(), test.response, i)
   297  	}
   298  }
   299  
   300  func Test403Handling(t *testing.T) {
   301  	cases := []struct {
   302  		path   string
   303  		header http.Header
   304  	}{
   305  		{"http://localhost:8001/css/style1.css", newHeader()},
   306  		{"http://localhost:8001/css/style1.css", newHeader("Accept-Encoding", "gzip")},
   307  	}
   308  
   309  	for i, test := range cases {
   310  		url := mustUrl("http://localhost:8001" + test.path)
   311  		request := &http.Request{Method: "GET", URL: url, Header: test.header}
   312  		a := NewAssetHandlerFS(&fs403{os.ErrPermission})
   313  		w := httptest.NewRecorder()
   314  
   315  		a.ServeHTTP(w, request)
   316  
   317  		isEqual(t, w.Code, 403, i)
   318  		isEqual(t, w.Header().Get("Content-Type"), "text/plain; charset=utf-8", i)
   319  		isEqual(t, w.Body.String(), "403 Forbidden\n", i)
   320  	}
   321  }
   322  
   323  func Test503Handling(t *testing.T) {
   324  	cases := []struct {
   325  		path   string
   326  		header http.Header
   327  	}{
   328  		{"http://localhost:8001/css/style1.css", newHeader()},
   329  		{"http://localhost:8001/css/style1.css", newHeader("Accept-Encoding", "gzip")},
   330  	}
   331  
   332  	for i, test := range cases {
   333  		url := mustUrl("http://localhost:8001" + test.path)
   334  		request := &http.Request{Method: "GET", URL: url, Header: test.header}
   335  		a := NewAssetHandlerFS(&fs403{os.ErrInvalid})
   336  		w := httptest.NewRecorder()
   337  
   338  		a.ServeHTTP(w, request)
   339  
   340  		isEqual(t, w.Code, 503, i)
   341  		isEqual(t, w.Header().Get("Content-Type"), "text/plain; charset=utf-8", i)
   342  		isNotEqual(t, w.Header().Get("Retry-After"), "", i)
   343  		isEqual(t, w.Body.String(), "503 Service unavailable\n", i)
   344  	}
   345  }
   346  
   347  //-------------------------------------------------------------------------------------------------
   348  
   349  func TestServeHTTP304(t *testing.T) {
   350  	cases := []struct {
   351  		url, path, encoding string
   352  		notFound            http.Handler
   353  	}{
   354  		{"http://localhost:8001/css/style1.css", "assets/css/style1.css.gz", "gzip", nil},
   355  		{"http://localhost:8001/css/style1.css", "assets/css/style1.css.br", "br", nil},
   356  		{"http://localhost:8001/css/style2.css", "assets/css/style2.css", "xx", nil},
   357  		{"http://localhost:8001/img/sort_asc.png", "assets/img/sort_asc.png", "xx", nil},
   358  		{"http://localhost:8001/js/script1.js", "assets/js/script1.js.gz", "gzip", nil},
   359  		{"http://localhost:8001/js/script1.js", "assets/js/script1.js.br", "br", nil},
   360  		{"http://localhost:8001/js/script2.js", "assets/js/script2.js", "xx", nil},
   361  
   362  		{"http://localhost:8001/css/style1.css", "assets/css/style1.css.gz", "gzip", &h404{}},
   363  		{"http://localhost:8001/css/style1.css", "assets/css/style1.css.br", "br", &h404{}},
   364  		{"http://localhost:8001/css/style2.css", "assets/css/style2.css", "xx", &h404{}},
   365  		{"http://localhost:8001/img/sort_asc.png", "assets/img/sort_asc.png", "xx", &h404{}},
   366  		{"http://localhost:8001/js/script1.js", "assets/js/script1.js.gz", "gzip", &h404{}},
   367  		{"http://localhost:8001/js/script1.js", "assets/js/script1.js.br", "br", &h404{}},
   368  		{"http://localhost:8001/js/script2.js", "assets/js/script2.js", "xx", &h404{}},
   369  	}
   370  
   371  	// net/http serveFiles handles conditional requests according to RFC723x specs.
   372  	// So we only need to check that a conditional request is correctly wired in.
   373  
   374  	for i, test := range cases {
   375  		etag := etagFor(test.path)
   376  		url := mustUrl(test.url)
   377  		header := newHeader("Accept-Encoding", test.encoding, "If-None-Match", etag)
   378  		request := &http.Request{Method: "GET", URL: url, Header: header}
   379  		a := NewAssetHandler("./assets/").WithNotFound(test.notFound)
   380  		w := httptest.NewRecorder()
   381  
   382  		a.ServeHTTP(w, request)
   383  
   384  		isEqual(t, w.Code, 304, i)
   385  		headers := w.Header()
   386  		//t.Logf("%+v\n", headers)
   387  		isGte(t, len(headers), 1, i)
   388  		isEqual(t, headers["Cache-Control"], emptyStrings, i)
   389  		isEqual(t, headers["Content-Type"], emptyStrings, i)
   390  		isEqual(t, headers["Content-Length"], emptyStrings, i)
   391  		if strings.HasSuffix(test.path, ".gz") {
   392  			isEqual(t, headers["Content-Encoding"], []string{"gzip"}, i)
   393  			isEqual(t, headers["Vary"], []string{"Accept-Encoding"}, i)
   394  			isEqual(t, headers["Etag"], []string{"W/" + etag}, i)
   395  		} else if strings.HasSuffix(test.path, ".br") {
   396  			isEqual(t, headers["Content-Encoding"], []string{"br"}, i)
   397  			isEqual(t, headers["Vary"], []string{"Accept-Encoding"}, i)
   398  			isEqual(t, headers["Etag"], []string{"W/" + etag}, i)
   399  		} else {
   400  			isEqual(t, headers["Content-Encoding"], emptyStrings, i)
   401  			isEqual(t, headers["Vary"], emptyStrings, i)
   402  			isEqual(t, headers["Etag"], []string{etag}, i)
   403  		}
   404  	}
   405  }
   406  
   407  func TestSPA(t *testing.T) {
   408  	cases := []struct {
   409  		path, conType, response string
   410  		code                    int
   411  	}{
   412  		{"/img/nonexisting.js", "text/plain; charset=utf-8", "404 Not found\n", 404},
   413  		{"/img/nonexisting", "", "", 301},
   414  		{"/img.de/nonexisting", "", "", 301},
   415  	}
   416  
   417  	for i, test := range cases {
   418  		url := mustUrl("http://localhost:8001" + test.path)
   419  		request := &http.Request{Method: "GET", URL: url}
   420  		a := NewAssetHandler("./assets/").WithSPA()
   421  		isEqual(t, a.Spa, true, i)
   422  		w := httptest.NewRecorder()
   423  
   424  		a.ServeHTTP(w, request)
   425  		log.Printf(w.Body.String())
   426  		isEqual(t, w.Code, test.code, i)
   427  		isEqual(t, w.Header().Get("Content-Type"), test.conType, i)
   428  		isEqual(t, w.Body.String(), test.response, i)
   429  	}
   430  }
   431  
   432  //-------------------------------------------------------------------------------------------------
   433  
   434  func Benchmark(t *testing.B) {
   435  	t.StopTimer()
   436  
   437  	cases := []struct {
   438  		strip       int
   439  		url, enc    string
   440  		sendEtagFor string
   441  		code        int
   442  	}{
   443  		{0, "css/style1.css", "gzip", "", 200},                             // has Gzip
   444  		{0, "css/style1.css", "br", "", 200},                               // has Brotli
   445  		{1, "a/css/style1.css", "gzip", "", 200},                           // has Gzip
   446  		{1, "a/css/style1.css", "br", "", 200},                             // has Brotli
   447  		{2, "a/b/css/style1.css", "gzip", "", 200},                         // has Gzip
   448  		{2, "a/b/css/style1.css", "br", "", 200},                           // has Brotli
   449  		{2, "a/b/css/style1.css", "xxxx", "", 200},                         // has Gzip
   450  		{2, "a/b/css/style1.css", "gzip", "assets/css/style1.css.gz", 304}, // has Gzip
   451  		{2, "a/b/css/style1.css", "br", "assets/css/style1.css.br", 304},   // has Brotli
   452  		{2, "a/b/css/style1.css", "xxxx", "assets/css/style1.css", 304},    // has Gzip
   453  
   454  		{2, "a/b/css/style2.css", "gzip", "", 200},
   455  		{2, "a/b/css/style2.css", "xxxx", "", 200},
   456  		{2, "a/b/css/style2.css", "gzip", "assets/css/style2.css", 304},
   457  		{2, "a/a/css/style2.css", "xxxx", "assets/css/style2.css", 304},
   458  
   459  		{2, "a/b/js/script1.js", "gzip", "", 200},                        // has gzip
   460  		{2, "a/b/js/script1.js", "br", "", 200},                          // has Brotli
   461  		{2, "a/b/js/script1.js", "xxxx", "", 200},                        // has gzip
   462  		{2, "a/b/js/script1.js", "gzip", "assets/js/script1.js.gz", 304}, // has gzip
   463  		{2, "a/b/js/script1.js", "br", "assets/js/script1.js.br", 304},   // has Brotli
   464  		{2, "a/a/js/script1.js", "xxxx", "assets/js/script1.js", 304},    // has gzip
   465  
   466  		{2, "a/b/js/script2.js", "gzip", "", 200},
   467  		{2, "a/b/js/script2.js", "xxxx", "", 200},
   468  		{2, "a/b/js/script2.js", "gzip", "assets/js/script2.js", 304},
   469  		{2, "a/a/js/script2.js", "xxxx", "assets/js/script2.js", 304},
   470  
   471  		{2, "a/b/img/sort_asc.png", "gzip", "", 200},
   472  		{2, "a/b/img/sort_asc.png", "xxxx", "", 200},
   473  		{2, "a/b/img/sort_asc.png", "gzip", "assets/img/sort_asc.png", 304},
   474  		{2, "a/a/img/sort_asc.png", "xxxx", "assets/img/sort_asc.png", 304},
   475  
   476  		{2, "a/b/img/nonexisting.png", "gzip", "", 404},
   477  		{2, "a/b/img/nonexisting.png", "xxxx", "", 404},
   478  	}
   479  
   480  	ages := []time.Duration{0, time.Hour}
   481  
   482  	for _, test := range cases {
   483  		header := newHeader("Accept-Encoding", test.enc)
   484  		etagOn := "no-etag"
   485  		if test.sendEtagFor != "" {
   486  			header = newHeader("Accept-Encoding", test.enc, "If-None-Match", etagFor(test.sendEtagFor))
   487  			etagOn = "etag"
   488  		}
   489  
   490  		for _, age := range ages {
   491  			a := NewAssetHandler("./assets/").StripOff(test.strip).WithMaxAge(age)
   492  
   493  			t.Run(fmt.Sprintf("%s~%s~%v~%d~%v", test.url, test.enc, etagOn, test.code, age), func(b *testing.B) {
   494  				b.StopTimer()
   495  
   496  				for i := 0; i < b.N; i++ {
   497  					url := mustUrl("http://localhost:8001/" + test.url)
   498  					request := &http.Request{Method: "GET", URL: url, Header: header}
   499  					w := httptest.NewRecorder()
   500  
   501  					b.StartTimer()
   502  					a.ServeHTTP(w, request)
   503  					b.StopTimer()
   504  
   505  					if w.Code != test.code {
   506  						b.Fatalf("Expected %d but got %d", test.code, w.Code)
   507  					}
   508  				}
   509  			})
   510  		}
   511  	}
   512  }
   513  
   514  //-------------------------------------------------------------------------------------------------
   515  
   516  func isEqual(t *testing.T, a, b, hint interface{}) {
   517  	t.Helper()
   518  	if !reflect.DeepEqual(a, b) {
   519  		t.Errorf("Got %#v; expected %#v - for %v\n", a, b, hint)
   520  	}
   521  }
   522  
   523  func isNotEqual(t *testing.T, a, b, hint interface{}) {
   524  	t.Helper()
   525  	if reflect.DeepEqual(a, b) {
   526  		t.Errorf("Got %#v; expected something else - for %v\n", a, hint)
   527  	}
   528  }
   529  
   530  func isGte(t *testing.T, a, b int, hint interface{}) {
   531  	t.Helper()
   532  	if a < b {
   533  		t.Errorf("Got %d; expected at least %d - for %v\n", a, b, hint)
   534  	}
   535  }
   536  
   537  func mustUrl(s string) *URL {
   538  	parsed, err := Parse(s)
   539  	must(err)
   540  	return parsed
   541  }
   542  
   543  func newHeader(kv ...string) http.Header {
   544  	header := make(http.Header)
   545  	for i, x := range kv {
   546  		if i%2 == 0 {
   547  			header[x] = []string{kv[i+1]}
   548  		}
   549  	}
   550  	return header
   551  }
   552  
   553  // must abort the program on error, printing a stack trace.
   554  func must(err error) {
   555  	if err != nil {
   556  		panic(err)
   557  	}
   558  }
   559  
   560  func mustStat(name string) os.FileInfo {
   561  	d, err := os.Stat(name)
   562  	if err != nil {
   563  		panic(err)
   564  	}
   565  	return d
   566  }
   567  
   568  func etagFor(name string) string {
   569  	d := mustStat(name)
   570  	t := ""
   571  	return fmt.Sprintf(`%s"%x-%x"`, t, d.ModTime().Unix(), d.Size())
   572  }
   573  
   574  //-------------------------------------------------------------------------------------------------
   575  
   576  type fs403 struct {
   577  	err error
   578  }
   579  
   580  func (fs fs403) Create(name string) (afero.File, error) {
   581  	return nil, fs.err
   582  }
   583  
   584  func (fs fs403) Mkdir(name string, perm os.FileMode) error {
   585  	return fs.err
   586  }
   587  
   588  func (fs fs403) MkdirAll(path string, perm os.FileMode) error {
   589  	return fs.err
   590  }
   591  
   592  func (fs fs403) Open(name string) (afero.File, error) {
   593  	return nil, fs.err
   594  }
   595  
   596  func (fs fs403) OpenFile(name string, flag int, perm os.FileMode) (afero.File, error) {
   597  	return nil, fs.err
   598  }
   599  
   600  func (fs fs403) Remove(name string) error {
   601  	return fs.err
   602  }
   603  
   604  func (fs fs403) RemoveAll(path string) error {
   605  	return fs.err
   606  }
   607  
   608  func (fs fs403) Rename(oldname, newname string) error {
   609  	return fs.err
   610  }
   611  
   612  func (fs fs403) Stat(name string) (os.FileInfo, error) {
   613  	return nil, fs.err
   614  }
   615  
   616  func (fs403) Name() string {
   617  	return "dumb"
   618  }
   619  
   620  func (fs fs403) Chmod(name string, mode os.FileMode) error {
   621  	return fs.err
   622  }
   623  
   624  func (fs fs403) Chtimes(name string, atime time.Time, mtime time.Time) error {
   625  	return fs.err
   626  }