pico
created pr with
91.1
added 91.2
1: a0c3196 ! 1: fbdea17 feat(pgs): show dir listing when no index.html present
cmds
checkout latest patchset:
ssh pr.pico.sh print 91 | git am -3checkout any patchset in a patch request:
ssh pr.pico.sh print 91.[rev] | git am -3add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh pr add 91
Patchset
91.2
feat(pgs): show dir listing when no index.html present
Eric Bower
2025-12-16T01:35:09ZSemantic diff summary
11 added,
1 modified,
0 signature changed,
0 removed
across 4 analyzed files
(1 file skipped: unsupported file type)
pkg/apps/pgs/gen_dir_listing.go
-
function_declarationgenerateDirectoryHTMLadded -
type_declarationdirEntryDisplayadded -
type_declarationDirectoryListingDataadded -
function_declarationformatFileSizeadded -
function_declarationsortEntriesadded -
function_declarationtoDisplayEntriesadded -
function_declarationshouldGenerateListingadded
+120
-0
pkg/apps/pgs/gen_dir_listing.go
#
| ... | ... | @@ -0,0 +1,120 @@ | |
| 1 | + | package pgs | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "embed" | |
| 6 | + | "fmt" | |
| 7 | + | "html/template" | |
| 8 | + | "os" | |
| 9 | + | "sort" | |
| 10 | + | ||
| 11 | + | sst "github.com/picosh/pico/pkg/pobj/storage" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | //go:embed html/* | |
| 15 | + | var dirListingFS embed.FS | |
| 16 | + | ||
| 17 | + | var dirListingTmpl = template.Must( | |
| 18 | + | template.New("base").ParseFS( | |
| 19 | + | dirListingFS, | |
| 20 | + | "html/base.layout.tmpl", | |
| 21 | + | "html/marketing-footer.partial.tmpl", | |
| 22 | + | "html/directory_listing.page.tmpl", | |
| 23 | + | ), | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | type dirEntryDisplay struct { | |
| 27 | + | Href string | |
| 28 | + | Display string | |
| 29 | + | Size string | |
| 30 | + | ModTime string | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | type DirectoryListingData struct { | |
| 34 | + | Path string | |
| 35 | + | ShowParent bool | |
| 36 | + | Entries []dirEntryDisplay | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func formatFileSize(size int64) string { | |
| 40 | + | const ( | |
| 41 | + | KB = 1024 | |
| 42 | + | MB = KB * 1024 | |
| 43 | + | GB = MB * 1024 | |
| 44 | + | ) | |
| 45 | + | ||
| 46 | + | switch { | |
| 47 | + | case size >= GB: | |
| 48 | + | return fmt.Sprintf("%.1f GB", float64(size)/float64(GB)) | |
| 49 | + | case size >= MB: | |
| 50 | + | return fmt.Sprintf("%.1f MB", float64(size)/float64(MB)) | |
| 51 | + | case size >= KB: | |
| 52 | + | return fmt.Sprintf("%.1f KB", float64(size)/float64(KB)) | |
| 53 | + | default: | |
| 54 | + | return fmt.Sprintf("%d B", size) | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func sortEntries(entries []os.FileInfo) { | |
| 59 | + | sort.Slice(entries, func(i, j int) bool { | |
| 60 | + | if entries[i].IsDir() != entries[j].IsDir() { | |
| 61 | + | return entries[i].IsDir() | |
| 62 | + | } | |
| 63 | + | return entries[i].Name() < entries[j].Name() | |
| 64 | + | }) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func toDisplayEntries(entries []os.FileInfo) []dirEntryDisplay { | |
| 68 | + | sortEntries(entries) | |
| 69 | + | displayEntries := make([]dirEntryDisplay, 0, len(entries)) | |
| 70 | + | ||
| 71 | + | for _, entry := range entries { | |
| 72 | + | display := dirEntryDisplay{ | |
| 73 | + | Href: entry.Name(), | |
| 74 | + | Display: entry.Name(), | |
| 75 | + | Size: formatFileSize(entry.Size()), | |
| 76 | + | ModTime: entry.ModTime().Format("2006-01-02 15:04"), | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | if entry.IsDir() { | |
| 80 | + | display.Href += "/" | |
| 81 | + | display.Display += "/" | |
| 82 | + | display.Size = "-" | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | displayEntries = append(displayEntries, display) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | return displayEntries | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func shouldGenerateListing(st sst.ObjectStorage, bucket sst.Bucket, projectDir string, path string) bool { | |
| 92 | + | dirPath := projectDir + path | |
| 93 | + | if path == "/" { | |
| 94 | + | dirPath = projectDir + "/" | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | entries, err := st.ListObjects(bucket, dirPath, false) | |
| 98 | + | if err != nil || len(entries) == 0 { | |
| 99 | + | return false | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | indexPath := dirPath + "index.html" | |
| 103 | + | _, _, err = st.GetObject(bucket, indexPath) | |
| 104 | + | return err != nil | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func generateDirectoryHTML(path string, entries []os.FileInfo) string { | |
| 108 | + | data := DirectoryListingData{ | |
| 109 | + | Path: path, | |
| 110 | + | ShowParent: path != "/", | |
| 111 | + | Entries: toDisplayEntries(entries), | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | var buf bytes.Buffer | |
| 115 | + | if err := dirListingTmpl.Execute(&buf, data); err != nil { | |
| 116 | + | return fmt.Sprintf("Error rendering directory listing: %s", err) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | return buf.String() | |
| 120 | + | } |
+200
-0
pkg/apps/pgs/gen_dir_listing_test.go
#
| ... | ... | @@ -0,0 +1,200 @@ | |
| 1 | + | package pgs | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | sst "github.com/picosh/pico/pkg/pobj/storage" | |
| 10 | + | "github.com/picosh/pico/pkg/send/utils" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestGenerateDirectoryHTML(t *testing.T) { | |
| 14 | + | fixtures := []struct { | |
| 15 | + | Name string | |
| 16 | + | Path string | |
| 17 | + | Entries []os.FileInfo | |
| 18 | + | Contains []string | |
| 19 | + | }{ | |
| 20 | + | { | |
| 21 | + | Name: "empty-directory", | |
| 22 | + | Path: "/", | |
| 23 | + | Entries: []os.FileInfo{}, | |
| 24 | + | Contains: []string{ | |
| 25 | + | "<title>Index of /</title>", | |
| 26 | + | "Index of /", | |
| 27 | + | }, | |
| 28 | + | }, | |
| 29 | + | { | |
| 30 | + | Name: "single-file", | |
| 31 | + | Path: "/", | |
| 32 | + | Entries: []os.FileInfo{ | |
| 33 | + | &utils.VirtualFile{FName: "hello.txt", FSize: 1024, FIsDir: false, FModTime: time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)}, | |
| 34 | + | }, | |
| 35 | + | Contains: []string{ | |
| 36 | + | "<title>Index of /</title>", | |
| 37 | + | `href="hello.txt"`, | |
| 38 | + | "hello.txt", | |
| 39 | + | "1.0 KB", | |
| 40 | + | }, | |
| 41 | + | }, | |
| 42 | + | { | |
| 43 | + | Name: "single-folder", | |
| 44 | + | Path: "/", | |
| 45 | + | Entries: []os.FileInfo{ | |
| 46 | + | &utils.VirtualFile{FName: "docs", FSize: 0, FIsDir: true, FModTime: time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)}, | |
| 47 | + | }, | |
| 48 | + | Contains: []string{ | |
| 49 | + | `href="docs/"`, | |
| 50 | + | "docs/", | |
| 51 | + | }, | |
| 52 | + | }, | |
| 53 | + | { | |
| 54 | + | Name: "mixed-entries", | |
| 55 | + | Path: "/assets/", | |
| 56 | + | Entries: []os.FileInfo{ | |
| 57 | + | &utils.VirtualFile{FName: "images", FSize: 0, FIsDir: true, FModTime: time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)}, | |
| 58 | + | &utils.VirtualFile{FName: "style.css", FSize: 2048, FIsDir: false, FModTime: time.Date(2025, 1, 14, 8, 0, 0, 0, time.UTC)}, | |
| 59 | + | &utils.VirtualFile{FName: "app.js", FSize: 512, FIsDir: false, FModTime: time.Date(2025, 1, 13, 12, 0, 0, 0, time.UTC)}, | |
| 60 | + | }, | |
| 61 | + | Contains: []string{ | |
| 62 | + | "<title>Index of /assets/</title>", | |
| 63 | + | `href="images/"`, | |
| 64 | + | `href="style.css"`, | |
| 65 | + | `href="app.js"`, | |
| 66 | + | "images/", | |
| 67 | + | "2.0 KB", | |
| 68 | + | }, | |
| 69 | + | }, | |
| 70 | + | { | |
| 71 | + | Name: "subdirectory-with-parent-link", | |
| 72 | + | Path: "/docs/api/", | |
| 73 | + | Entries: []os.FileInfo{ | |
| 74 | + | &utils.VirtualFile{FName: "readme.md", FSize: 256, FIsDir: false, FModTime: time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)}, | |
| 75 | + | }, | |
| 76 | + | Contains: []string{ | |
| 77 | + | "<title>Index of /docs/api/</title>", | |
| 78 | + | `href="../"`, | |
| 79 | + | "../", | |
| 80 | + | }, | |
| 81 | + | }, | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | for _, fixture := range fixtures { | |
| 85 | + | t.Run(fixture.Name, func(t *testing.T) { | |
| 86 | + | html := generateDirectoryHTML(fixture.Path, fixture.Entries) | |
| 87 | + | ||
| 88 | + | for _, expected := range fixture.Contains { | |
| 89 | + | if !strings.Contains(html, expected) { | |
| 90 | + | t.Errorf("expected HTML to contain %q, got:\n%s", expected, html) | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | }) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func TestSortEntries(t *testing.T) { | |
| 98 | + | entries := []os.FileInfo{ | |
| 99 | + | &utils.VirtualFile{FName: "zebra.txt", FIsDir: false}, | |
| 100 | + | &utils.VirtualFile{FName: "alpha", FIsDir: true}, | |
| 101 | + | &utils.VirtualFile{FName: "beta.md", FIsDir: false}, | |
| 102 | + | &utils.VirtualFile{FName: "zulu", FIsDir: true}, | |
| 103 | + | &utils.VirtualFile{FName: "apple.js", FIsDir: false}, | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | sortEntries(entries) | |
| 107 | + | ||
| 108 | + | expected := []string{"alpha", "zulu", "apple.js", "beta.md", "zebra.txt"} | |
| 109 | + | for i, entry := range entries { | |
| 110 | + | if entry.Name() != expected[i] { | |
| 111 | + | t.Errorf("position %d: expected %q, got %q", i, expected[i], entry.Name()) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func TestShouldGenerateListing(t *testing.T) { | |
| 117 | + | fixtures := []struct { | |
| 118 | + | Name string | |
| 119 | + | Path string | |
| 120 | + | Storage map[string]map[string]string | |
| 121 | + | Expected bool | |
| 122 | + | }{ | |
| 123 | + | { | |
| 124 | + | Name: "directory-with-index-html", | |
| 125 | + | Path: "/docs/", | |
| 126 | + | Storage: map[string]map[string]string{ | |
| 127 | + | "testbucket": { | |
| 128 | + | "/project/docs/index.html": "<html>hello</html>", | |
| 129 | + | }, | |
| 130 | + | }, | |
| 131 | + | Expected: false, | |
| 132 | + | }, | |
| 133 | + | { | |
| 134 | + | Name: "directory-without-index-html", | |
| 135 | + | Path: "/docs/", | |
| 136 | + | Storage: map[string]map[string]string{ | |
| 137 | + | "testbucket": { | |
| 138 | + | "/project/docs/readme.md": "# Readme", | |
| 139 | + | "/project/docs/guide.md": "# Guide", | |
| 140 | + | }, | |
| 141 | + | }, | |
| 142 | + | Expected: true, | |
| 143 | + | }, | |
| 144 | + | { | |
| 145 | + | Name: "empty-directory", | |
| 146 | + | Path: "/empty/", | |
| 147 | + | Storage: map[string]map[string]string{ | |
| 148 | + | "testbucket": { | |
| 149 | + | "/project/other/file.txt": "content", | |
| 150 | + | }, | |
| 151 | + | }, | |
| 152 | + | Expected: false, | |
| 153 | + | }, | |
| 154 | + | { | |
| 155 | + | Name: "root-directory-without-index", | |
| 156 | + | Path: "/", | |
| 157 | + | Storage: map[string]map[string]string{ | |
| 158 | + | "testbucket": { | |
| 159 | + | "/project/style.css": "body {}", | |
| 160 | + | "/project/app.js": "console.log('hi')", | |
| 161 | + | }, | |
| 162 | + | }, | |
| 163 | + | Expected: true, | |
| 164 | + | }, | |
| 165 | + | { | |
| 166 | + | Name: "root-directory-with-index", | |
| 167 | + | Path: "/", | |
| 168 | + | Storage: map[string]map[string]string{ | |
| 169 | + | "testbucket": { | |
| 170 | + | "/project/index.html": "<html>home</html>", | |
| 171 | + | }, | |
| 172 | + | }, | |
| 173 | + | Expected: false, | |
| 174 | + | }, | |
| 175 | + | { | |
| 176 | + | Name: "nested-directory-without-index", | |
| 177 | + | Path: "/assets/images/", | |
| 178 | + | Storage: map[string]map[string]string{ | |
| 179 | + | "testbucket": { | |
| 180 | + | "/project/assets/images/logo.png": "png data", | |
| 181 | + | "/project/assets/images/banner.jpg": "jpg data", | |
| 182 | + | }, | |
| 183 | + | }, | |
| 184 | + | Expected: true, | |
| 185 | + | }, | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | for _, fixture := range fixtures { | |
| 189 | + | t.Run(fixture.Name, func(t *testing.T) { | |
| 190 | + | st, _ := sst.NewStorageMemory(fixture.Storage) | |
| 191 | + | bucket := sst.Bucket{Name: "testbucket", Path: "testbucket"} | |
| 192 | + | ||
| 193 | + | result := shouldGenerateListing(st, bucket, "project", fixture.Path) | |
| 194 | + | ||
| 195 | + | if result != fixture.Expected { | |
| 196 | + | t.Errorf("shouldGenerateListing(%q) = %v, want %v", fixture.Path, result, fixture.Expected) | |
| 197 | + | } | |
| 198 | + | }) | |
| 199 | + | } | |
| 200 | + | } |
+30
-0
pkg/apps/pgs/html/directory_listing.page.tmpl
#
| ... | ... | @@ -0,0 +1,30 @@ | |
| 1 | + | {{template "base" .}} | |
| 2 | + | ||
| 3 | + | {{define "title"}}Index of {{.Path}}{{end}} | |
| 4 | + | ||
| 5 | + | {{define "meta"}}{{end}} | |
| 6 | + | ||
| 7 | + | {{define "attrs"}}class="container"{{end}} | |
| 8 | + | ||
| 9 | + | {{define "body"}} | |
| 10 | + | <header> | |
| 11 | + | <h1 class="text-2xl">Index of {{.Path}}</h1> | |
| 12 | + | <hr /> | |
| 13 | + | </header> | |
| 14 | + | <main> | |
| 15 | + | <table> | |
| 16 | + | <thead> | |
| 17 | + | <tr><th>Name</th><th>Size</th><th>Modified</th></tr> | |
| 18 | + | </thead> | |
| 19 | + | <tbody> | |
| 20 | + | {{- if .ShowParent}} | |
| 21 | + | <tr><td><a href="../">../</a></td><td>-</td><td>-</td></tr> | |
| 22 | + | {{- end}} | |
| 23 | + | {{- range .Entries}} | |
| 24 | + | <tr><td><a href="{{.Href}}">{{.Display}}</a></td><td>{{.Size}}</td><td>{{.ModTime}}</td></tr> | |
| 25 | + | {{- end}} | |
| 26 | + | </tbody> | |
| 27 | + | </table> | |
| 28 | + | </main> | |
| 29 | + | {{template "marketing-footer" .}} | |
| 30 | + | {{end}} |
+21
-0
pkg/apps/pgs/web_asset_handler.go
#
| ... | ... | @@ -175,6 +175,27 @@ func (h *ApiAssetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |
| 175 | 175 | } | |
| 176 | 176 | ||
| 177 | 177 | if assetFilepath == "" { | |
| 178 | + | if shouldGenerateListing(h.Cfg.Storage, h.Bucket, h.ProjectDir, "/"+fpath) { | |
| 179 | + | logger.Info( | |
| 180 | + | "generating directory listing", | |
| 181 | + | "path", fpath, | |
| 182 | + | ) | |
| 183 | + | dirPath := h.ProjectDir + "/" + fpath | |
| 184 | + | entries, err := h.Cfg.Storage.ListObjects(h.Bucket, dirPath, false) | |
| 185 | + | if err == nil { | |
| 186 | + | requestPath := "/" + fpath | |
| 187 | + | if !strings.HasSuffix(requestPath, "/") { | |
| 188 | + | requestPath += "/" | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | html := generateDirectoryHTML(requestPath, entries) | |
| 192 | + | w.Header().Set("content-type", "text/html") | |
| 193 | + | w.WriteHeader(http.StatusOK) | |
| 194 | + | _, _ = w.Write([]byte(html)) | |
| 195 | + | return | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | ||
| 178 | 199 | logger.Info( | |
| 179 | 200 | "asset not found in bucket", | |
| 180 | 201 | "routes", strings.Join(attempts, ", "), |
+117
-0
pkg/apps/pgs/web_test.go
#
| ... | ... | @@ -358,6 +358,123 @@ func TestApiBasic(t *testing.T) { | |
| 358 | 358 | } | |
| 359 | 359 | } | |
| 360 | 360 | ||
| 361 | + | func TestDirectoryListing(t *testing.T) { | |
| 362 | + | logger := slog.Default() | |
| 363 | + | dbpool := NewPgsDb(logger) | |
| 364 | + | bucketName := shared.GetAssetBucketName(dbpool.Users[0].ID) | |
| 365 | + | ||
| 366 | + | tt := []struct { | |
| 367 | + | name string | |
| 368 | + | path string | |
| 369 | + | status int | |
| 370 | + | contentType string | |
| 371 | + | contains []string | |
| 372 | + | notContains []string | |
| 373 | + | storage map[string]map[string]string | |
| 374 | + | }{ | |
| 375 | + | { | |
| 376 | + | name: "directory-without-index-shows-listing", | |
| 377 | + | path: "/docs/", | |
| 378 | + | status: http.StatusOK, | |
| 379 | + | contentType: "text/html", | |
| 380 | + | contains: []string{ | |
| 381 | + | "Index of /docs/", | |
| 382 | + | "readme.md", | |
| 383 | + | "guide.md", | |
| 384 | + | }, | |
| 385 | + | storage: map[string]map[string]string{ | |
| 386 | + | bucketName: { | |
| 387 | + | "/test/docs/readme.md": "# Readme", | |
| 388 | + | "/test/docs/guide.md": "# Guide", | |
| 389 | + | }, | |
| 390 | + | }, | |
| 391 | + | }, | |
| 392 | + | { | |
| 393 | + | name: "directory-with-index-serves-index", | |
| 394 | + | path: "/docs/", | |
| 395 | + | status: http.StatusOK, | |
| 396 | + | contentType: "text/html", | |
| 397 | + | contains: []string{"hello world!"}, | |
| 398 | + | notContains: []string{"Index of"}, | |
| 399 | + | storage: map[string]map[string]string{ | |
| 400 | + | bucketName: { | |
| 401 | + | "/test/docs/index.html": "hello world!", | |
| 402 | + | "/test/docs/readme.md": "# Readme", | |
| 403 | + | }, | |
| 404 | + | }, | |
| 405 | + | }, | |
| 406 | + | { | |
| 407 | + | name: "root-directory-without-index-shows-listing", | |
| 408 | + | path: "/", | |
| 409 | + | status: http.StatusOK, | |
| 410 | + | contentType: "text/html", | |
| 411 | + | contains: []string{ | |
| 412 | + | "Index of /", | |
| 413 | + | "style.css", | |
| 414 | + | }, | |
| 415 | + | storage: map[string]map[string]string{ | |
| 416 | + | bucketName: { | |
| 417 | + | "/test/style.css": "body {}", | |
| 418 | + | }, | |
| 419 | + | }, | |
| 420 | + | }, | |
| 421 | + | { | |
| 422 | + | name: "nested-directory-shows-parent-link", | |
| 423 | + | path: "/assets/images/", | |
| 424 | + | status: http.StatusOK, | |
| 425 | + | contentType: "text/html", | |
| 426 | + | contains: []string{ | |
| 427 | + | "Index of /assets/images/", | |
| 428 | + | `href="../"`, | |
| 429 | + | "logo.png", | |
| 430 | + | }, | |
| 431 | + | storage: map[string]map[string]string{ | |
| 432 | + | bucketName: { | |
| 433 | + | "/test/assets/images/logo.png": "png data", | |
| 434 | + | }, | |
| 435 | + | }, | |
| 436 | + | }, | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | for _, tc := range tt { | |
| 440 | + | t.Run(tc.name, func(t *testing.T) { | |
| 441 | + | request := httptest.NewRequest("GET", dbpool.mkpath(tc.path), strings.NewReader("")) | |
| 442 | + | responseRecorder := httptest.NewRecorder() | |
| 443 | + | ||
| 444 | + | st, _ := storage.NewStorageMemory(tc.storage) | |
| 445 | + | pubsub := NewPubsubChan() | |
| 446 | + | defer func() { | |
| 447 | + | _ = pubsub.Close() | |
| 448 | + | }() | |
| 449 | + | cfg := NewPgsConfig(logger, dbpool, st, pubsub) | |
| 450 | + | cfg.Domain = "pgs.test" | |
| 451 | + | router := NewWebRouter(cfg) | |
| 452 | + | router.ServeHTTP(responseRecorder, request) | |
| 453 | + | ||
| 454 | + | if responseRecorder.Code != tc.status { | |
| 455 | + | t.Errorf("Want status '%d', got '%d'", tc.status, responseRecorder.Code) | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | ct := responseRecorder.Header().Get("content-type") | |
| 459 | + | if ct != tc.contentType { | |
| 460 | + | t.Errorf("Want content type '%s', got '%s'", tc.contentType, ct) | |
| 461 | + | } | |
| 462 | + | ||
| 463 | + | body := responseRecorder.Body.String() | |
| 464 | + | for _, want := range tc.contains { | |
| 465 | + | if !strings.Contains(body, want) { | |
| 466 | + | t.Errorf("Want body to contain '%s', got '%s'", want, body) | |
| 467 | + | } | |
| 468 | + | } | |
| 469 | + | for _, notWant := range tc.notContains { | |
| 470 | + | if strings.Contains(body, notWant) { | |
| 471 | + | t.Errorf("Want body to NOT contain '%s', got '%s'", notWant, body) | |
| 472 | + | } | |
| 473 | + | } | |
| 474 | + | }) | |
| 475 | + | } | |
| 476 | + | } | |
| 477 | + | ||
| 361 | 478 | type ImageStorageMemory struct { | |
| 362 | 479 | *storage.StorageMemory | |
| 363 | 480 | Opts *storage.ImgProcessOpts |