package pages import ( "errors" "html/template" "net/http" "net/http/httptest" "testing" "testing/fstest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // view is the dot every test renders through: the shape the donors' view // structs have — chrome fields (here only a title) plus the page's payload // under .Data, which is what the shipped error page reads. type view struct { Title string Data any } // chromeMark is written by the layout before it reaches the content hole, so a // test can tell "nothing was written" from "half a page was written". const chromeMark = "CHROME-MARK" // boomer fails at execution and only there: html/template reports an error // returned by a method as a render error, which is the mid-render failure the // buffering exists for. Its message names something a viewer must never see. type boomer struct{} func (boomer) Boom() (string, error) { return "", errors.New("kaboom /etc/srht/config.ini") } // testFS is a service's template tree: a layout, a page, a partial, and a page // that fails halfway through rendering. func testFS() fstest.MapFS { return fstest.MapFS{ "templates/layout.html": &fstest.MapFile{Data: []byte( `{{.Title}}` + chromeMark + `{{template "content" .}}`)}, "templates/index.html": &fstest.MapFile{Data: []byte( `{{define "content"}}

index {{template "_row" .Data}}

{{end}}`)}, "templates/_row.html": &fstest.MapFile{Data: []byte( `{{define "_row"}}{{.}}{{end}}`)}, "templates/boom.html": &fstest.MapFile{Data: []byte( `{{define "content"}}

{{.Data.Boom}}

{{end}}`)}, } } func TestLoadDiscoversPagesPartialsAndTheShippedErrorPage(t *testing.T) { set, err := Load(testFS(), Options{}) require.NoError(t, err) // The pages, plus the error page this package ships. The layout and the // partial are not pages and must not be renderable by name. assert.ElementsMatch(t, []string{"index", "boom", ErrorPage}, keys(set)) } func TestLoadParsesEveryPartialIntoEveryPage(t *testing.T) { set, err := Load(testFS(), Options{}) require.NoError(t, err) // A partial is parsed into every set, not only into the page that invokes // it today — including the error page, which invokes none of them. for name, tmpl := range set { assert.NotNil(t, tmpl.Lookup("_row"), "local partial missing from %q", name) assert.NotNil(t, tmpl.Lookup(ErrorPartial), "error partial missing from %q", name) assert.NotNil(t, tmpl.Lookup("srht-nav"), "chrome partial missing from %q", name) } } func TestLoadRefusesAPageThatDefinesNoContent(t *testing.T) { fsys := testFS() // A page that renders nothing into the hole: executed, it would answer 200 // with the chrome around an empty document. fsys["templates/blank.html"] = &fstest.MapFile{Data: []byte(`

forgot the define

`)} _, err := Load(fsys, Options{}) require.Error(t, err) assert.ErrorIs(t, err, ErrNoContent) assert.Contains(t, err.Error(), "blank.html") } func TestLoadRefusesATreeWithoutALayout(t *testing.T) { fsys := testFS() delete(fsys, "templates/layout.html") _, err := Load(fsys, Options{}) require.Error(t, err) assert.Contains(t, err.Error(), "no layout") } func TestLoadRefusesATreeWithNoPages(t *testing.T) { fsys := fstest.MapFS{ "templates/layout.html": &fstest.MapFile{Data: []byte(`{{template "content" .}}`)}, "templates/_row.html": &fstest.MapFile{Data: []byte(`{{define "_row"}}{{end}}`)}, } _, err := Load(fsys, Options{}) require.Error(t, err) assert.Contains(t, err.Error(), "no page templates") } func TestLoadMergesTheServiceFuncsOverChromes(t *testing.T) { fsys := testFS() fsys["templates/index.html"] = &fstest.MapFile{Data: []byte( `{{define "content"}}{{shortsha "0123456789"}}|{{shout "hi"}}{{end}}`)} set, err := Load(fsys, Options{Funcs: template.FuncMap{ "shout": func(s string) string { return s + "!" }, "shortsha": func(string) string { return "SHADOWED" }, }}) require.NoError(t, err) rec := httptest.NewRecorder() require.NoError(t, set.Render(rec, http.StatusOK, "index", view{})) assert.Contains(t, rec.Body.String(), "SHADOWED|hi!") } func TestRenderWritesTheStatusAndTheBufferedPage(t *testing.T) { set, err := Load(testFS(), Options{}) require.NoError(t, err) rec := httptest.NewRecorder() require.NoError(t, set.Render(rec, http.StatusCreated, "index", view{Title: "T", Data: "payload"})) assert.Equal(t, http.StatusCreated, rec.Code) assert.Equal(t, "text/html; charset=utf-8", rec.Header().Get("Content-Type")) body := rec.Body.String() assert.Contains(t, body, "T") assert.Contains(t, body, chromeMark) assert.Contains(t, body, "payload") } func TestRenderFailureLeavesNoPartialPageAndNoTemplateText(t *testing.T) { set, err := Load(testFS(), Options{}) require.NoError(t, err) rec := httptest.NewRecorder() err = set.Render(rec, http.StatusOK, "boom", view{Title: "T", Data: boomer{}}) // The caller gets the error to log, naming the page and carrying the cause. require.Error(t, err) assert.Contains(t, err.Error(), `"boom"`) assert.Contains(t, err.Error(), "kaboom") // The viewer gets a clean 500 and nothing else: not the status the handler // asked for, not the chrome the template had already produced when it // failed, and above all not the template's own error text — which is what // the dolt donor wrote into the body. assert.Equal(t, http.StatusInternalServerError, rec.Code) assert.Equal(t, internalServerError+"\n", rec.Body.String()) assert.NotContains(t, rec.Body.String(), chromeMark) assert.NotContains(t, rec.Body.String(), "kaboom") assert.NotContains(t, rec.Body.String(), "config.ini") } func TestRenderRefusesAnUnknownPage(t *testing.T) { set, err := Load(testFS(), Options{}) require.NoError(t, err) rec := httptest.NewRecorder() err = set.Render(rec, http.StatusOK, "nope", view{}) require.Error(t, err) assert.ErrorIs(t, err, ErrUnknownPage) assert.Equal(t, http.StatusInternalServerError, rec.Code) assert.Equal(t, internalServerError+"\n", rec.Body.String()) } func TestErrorPageRendersTheStatusTheMessageAndTheWayBack(t *testing.T) { set, err := Load(testFS(), Options{}) require.NoError(t, err) data := Error(http.StatusNotFound, "").BackTo("/tokens", "Back to your tokens") rec := httptest.NewRecorder() require.NoError(t, set.Render(rec, data.Status, ErrorPage, view{Title: data.StatusText, Data: data})) assert.Equal(t, http.StatusNotFound, rec.Code) body := rec.Body.String() assert.Contains(t, body, chromeMark, "the error page is rendered inside the chrome") assert.Contains(t, body, "404") assert.Contains(t, body, "Not Found") assert.Contains(t, body, NotFoundMessage) assert.Contains(t, body, `Back to your tokens`) } func TestErrorPageEscapesTheMessage(t *testing.T) { set, err := Load(testFS(), Options{}) require.NoError(t, err) // A 400's message quotes what the caller typed, so it is the one that can // carry markup. data := Error(http.StatusBadRequest, ` is not a duration`) rec := httptest.NewRecorder() require.NoError(t, set.Render(rec, data.Status, ErrorPage, view{Data: data})) body := rec.Body.String() assert.NotContains(t, body, "