From 388718598ecf80819f250e707d900c8a6672bcf2 Mon Sep 17 00:00:00 2001 From: mikestefanello Date: Sat, 25 Dec 2021 11:21:26 -0500 Subject: [PATCH] Added test coverage for template renderer. --- controller/controller.go | 15 +++-- controller/controller_test.go | 34 +++++++++++ routes/router.go | 3 +- routes/routes_test.go | 4 +- services/template_renderer.go | 92 +++++++++++++++++++++--------- services/template_renderer_test.go | 70 +++++++++++++++++++++++ 6 files changed, 183 insertions(+), 35 deletions(-) create mode 100644 services/template_renderer_test.go diff --git a/controller/controller.go b/controller/controller.go index 050ebd7..7579d7b 100644 --- a/controller/controller.go +++ b/controller/controller.go @@ -46,12 +46,15 @@ func (c *Controller) RenderPage(ctx echo.Context, page Page) error { page.AppName = c.Container.Config.App.Name } - // Check if this is an HTMX request + // Check if this is an HTMX non-boosted request which indicates that only partial + // content should be rendered if page.HTMX.Request.Enabled && !page.HTMX.Request.Boosted { - // Disable caching - page.Cache.Enabled = false - - // Parse and execute + // Parse and execute the templates only for the content portion of the page + // The templates used for this partial request will be: + // 1. The base htmx template which omits the layout and only includes the content template + // 2. The content template specified in Page.Name + // 3. All templates within the components directory + // Also included is the function map provided by the funcmap package buf, err = c.Container.TemplateRenderer.ParseAndExecute( "page:htmx", page.Name, @@ -150,10 +153,10 @@ func (c *Controller) cachePage(ctx echo.Context, page Page, html *bytes.Buffer) // Redirect redirects to a given route name with optional route parameters func (c *Controller) Redirect(ctx echo.Context, route string, routeParams ...interface{}) error { url := ctx.Echo().Reverse(route, routeParams) - // TODO: HTMX redirect? return ctx.Redirect(http.StatusFound, url) } +// Fail is a helper to fail a request by returning a 500 error and logging the error func (c *Controller) Fail(ctx echo.Context, err error, log string) error { ctx.Logger().Errorf("%s: %v", log, err) return echo.NewHTTPError(http.StatusInternalServerError) diff --git a/controller/controller_test.go b/controller/controller_test.go index 62b5d94..3c85b19 100644 --- a/controller/controller_test.go +++ b/controller/controller_test.go @@ -9,6 +9,7 @@ import ( "testing" "goweb/config" + "goweb/htmx" "goweb/middleware" "goweb/services" "goweb/tests" @@ -109,6 +110,39 @@ func TestController_RenderPage(t *testing.T) { assert.Empty(t, expectedTemplates) }) + t.Run("htmx rendering", func(t *testing.T) { + ctx, _, ctr, p := setup() + p.HTMX.Request.Enabled = true + p.HTMX.Response = &htmx.Response{ + Trigger: "trigger", + } + err := ctr.RenderPage(ctx, p) + require.NoError(t, err) + + // Check HTMX header + assert.Equal(t, "trigger", ctx.Response().Header().Get(htmx.HeaderTrigger)) + + // Check the template cache + parsed, err := c.TemplateRenderer.Load("page:htmx", p.Name) + assert.NoError(t, err) + + // Check that all expected templates were parsed. + // This includes the name, htmx and all components + expectedTemplates := make(map[string]bool) + expectedTemplates[p.Name+config.TemplateExt] = true + expectedTemplates["htmx"+config.TemplateExt] = true + components, err := ioutil.ReadDir(c.TemplateRenderer.GetTemplatesPath() + "/components") + require.NoError(t, err) + for _, f := range components { + expectedTemplates[f.Name()] = true + } + + for _, v := range parsed.Templates() { + delete(expectedTemplates, v.Name()) + } + assert.Empty(t, expectedTemplates) + }) + t.Run("page cache", func(t *testing.T) { ctx, rec, ctr, p := setup() p.Cache.Enabled = true diff --git a/routes/router.go b/routes/router.go index 41e27ad..3b83724 100644 --- a/routes/router.go +++ b/routes/router.go @@ -15,6 +15,7 @@ import ( echomw "github.com/labstack/echo/v4/middleware" ) +// BuildRouter builds the router func BuildRouter(c *services.Container) { // Static files with proper cache control // funcmap.File() should be used in templates to append a cache key to the URL in order to break cache @@ -58,7 +59,7 @@ func BuildRouter(c *services.Container) { err := Error{Controller: ctr} c.Web.HTTPErrorHandler = err.Get - // Routes + // Example routes navRoutes(c, g, ctr) userRoutes(c, g, ctr) } diff --git a/routes/routes_test.go b/routes/routes_test.go index bb8dfce..b7552be 100644 --- a/routes/routes_test.go +++ b/routes/routes_test.go @@ -102,8 +102,8 @@ func (h *httpResponse) assertStatusCode(code int) *httpResponse { return h } -func (h *httpResponse) assertRedirect(t *testing.T, destination string) *httpResponse { - assert.Equal(t, destination, h.Header.Get("Location")) +func (h *httpResponse) assertRedirect(t *testing.T, route string, params ...interface{}) *httpResponse { + assert.Equal(t, c.Web.Reverse(route, params), h.Header.Get("Location")) return h } diff --git a/services/template_renderer.go b/services/template_renderer.go index 347a798..aadb7b9 100644 --- a/services/template_renderer.go +++ b/services/template_renderer.go @@ -39,7 +39,7 @@ func NewTemplateRenderer(cfg *config.Config) *TemplateRenderer { } // Gets the complete templates directory path - // This is needed incase this is called from a package outside of main, such as within tests + // This is needed in case this is called from a package outside of main, such as within tests _, b, _, _ := runtime.Caller(0) d := path.Join(path.Dir(b)) t.templatesPath = filepath.Join(filepath.Dir(d), config.TemplateDir) @@ -47,28 +47,48 @@ func NewTemplateRenderer(cfg *config.Config) *TemplateRenderer { return t } -func (t *TemplateRenderer) ParseAndExecute(group, id, name string, files []string, directories []string, data interface{}) (*bytes.Buffer, error) { - var buf *bytes.Buffer - var err error - - if err = t.Parse(group, id, name, files, directories); err != nil { - return nil, err - } - if buf, err = t.Execute(group, id, name, data); err != nil { - return nil, err - } - - return buf, nil -} - -func (t *TemplateRenderer) Parse(group, id, name string, files []string, directories []string) error { - cacheKey := t.getCacheKey(group, id) +// Parse parses a set of templates and caches them for quick execution +// If the application environment is set to local, the cache will be bypassed and templates will be +// parsed upon each request so hot-reloading is possible without restarts. +// +// All template files and template directories must be provided relative to the templates directory +// and without template extensions. Those two values can be altered via the config package. +// +// cacheGroup is used to separate templates in to groups within the cache to avoid potential conflicts +// with the cacheID. +// +// baseName is the filename of the base template without any paths or an extension. +// files is a slice of all individual template files that will be included in the parse. +// directories is a slice of directories which all template files witin them will be included in the parse +// +// Also included will be the function map provided by the funcmap package. +// +// An example usage of this: +// t.Parse( +// "page", +// "home", +// "main", +// []string{ +// "layouts/main", +// "pages/home", +// }, +// []string{"components"}, +//) +// +// This will perform a template parse which will: +// - Be cached using a key of "page:home" +// - Include the layouts/main.gohtml and pages/home.gohtml templates +// - Include all templates within the components directory +// - Include the function map within the funcmap package +// - Set the base template as main.gohtml +func (t *TemplateRenderer) Parse(cacheGroup, cacheID, baseName string, files []string, directories []string) error { + cacheKey := t.getCacheKey(cacheGroup, cacheID) // Check if the template has not yet been parsed or if the app environment is local, so that // templates reflect changes without having the restart the server - if _, err := t.Load(group, id); err != nil || t.config.App.Environment == config.EnvLocal { + if _, err := t.Load(cacheGroup, cacheID); err != nil || t.config.App.Environment == config.EnvLocal { // Initialize the parsed template with the function map - parsed := template.New(name + config.TemplateExt). + parsed := template.New(baseName + config.TemplateExt). Funcs(t.funcMap) // Parse all files provided @@ -99,14 +119,16 @@ func (t *TemplateRenderer) Parse(group, id, name string, files []string, directo return nil } -func (t *TemplateRenderer) Execute(group, id, name string, data interface{}) (*bytes.Buffer, error) { - tmpl, err := t.Load(group, id) +// Execute executes a cached template with the data provided +// See Parse() for an explanation of the parameters +func (t *TemplateRenderer) Execute(cacheGroup, cacheID, baseName string, data interface{}) (*bytes.Buffer, error) { + tmpl, err := t.Load(cacheGroup, cacheID) if err != nil { return nil, err } buf := new(bytes.Buffer) - err = tmpl.ExecuteTemplate(buf, name+config.TemplateExt, data) + err = tmpl.ExecuteTemplate(buf, baseName+config.TemplateExt, data) if err != nil { return nil, err } @@ -114,8 +136,24 @@ func (t *TemplateRenderer) Execute(group, id, name string, data interface{}) (*b return buf, nil } -func (t *TemplateRenderer) Load(group, id string) (*template.Template, error) { - load, ok := t.templateCache.Load(t.getCacheKey(group, id)) +// ParseAndExecute is a wrapper around Parse() and Execute() +func (t *TemplateRenderer) ParseAndExecute(cacheGroup, cacheID, baseName string, files []string, directories []string, data interface{}) (*bytes.Buffer, error) { + var buf *bytes.Buffer + var err error + + if err = t.Parse(cacheGroup, cacheID, baseName, files, directories); err != nil { + return nil, err + } + if buf, err = t.Execute(cacheGroup, cacheID, baseName, data); err != nil { + return nil, err + } + + return buf, nil +} + +// Load loads a template from the cache +func (t *TemplateRenderer) Load(cacheGroup, cacheID string) (*template.Template, error) { + load, ok := t.templateCache.Load(t.getCacheKey(cacheGroup, cacheID)) if !ok { return nil, errors.New("uncached page template requested") } @@ -128,10 +166,12 @@ func (t *TemplateRenderer) Load(group, id string) (*template.Template, error) { return tmpl, nil } +// GetTemplatesPath gets the complete path to the templates directory func (t *TemplateRenderer) GetTemplatesPath() string { return t.templatesPath } -func (t *TemplateRenderer) getCacheKey(group, id string) string { - return fmt.Sprintf("%s:%s", group, id) +// getCacheKey gets a cache key for a given group and ID +func (t *TemplateRenderer) getCacheKey(cacheGroup, cacheID string) string { + return fmt.Sprintf("%s:%s", cacheGroup, cacheID) } diff --git a/services/template_renderer_test.go b/services/template_renderer_test.go new file mode 100644 index 0000000..07d9a1f --- /dev/null +++ b/services/template_renderer_test.go @@ -0,0 +1,70 @@ +package services + +import ( + "io/ioutil" + "testing" + + "goweb/config" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTemplateRenderer(t *testing.T) { + group := "test" + id := "parse" + + // Should not exist yet + _, err := c.TemplateRenderer.Load(group, id) + assert.Error(t, err) + + // Parse in to the cache + err = c.TemplateRenderer.Parse( + group, + id, + "htmx", + []string{"htmx", "pages/error"}, + []string{"components"}, + ) + require.NoError(t, err) + + // Should exist now + parsed, err := c.TemplateRenderer.Load(group, id) + require.NoError(t, err) + + // Check that all expected templates are included + expectedTemplates := make(map[string]bool) + expectedTemplates["htmx"+config.TemplateExt] = true + expectedTemplates["error"+config.TemplateExt] = true + components, err := ioutil.ReadDir(c.TemplateRenderer.GetTemplatesPath() + "/components") + require.NoError(t, err) + for _, f := range components { + expectedTemplates[f.Name()] = true + } + for _, v := range parsed.Templates() { + delete(expectedTemplates, v.Name()) + } + assert.Empty(t, expectedTemplates) + + data := struct { + StatusCode int + }{ + StatusCode: 500, + } + buf, err := c.TemplateRenderer.Execute(group, id, "htmx", data) + require.NoError(t, err) + require.NotNil(t, buf) + assert.Contains(t, buf.String(), "Please try again") + + buf, err = c.TemplateRenderer.ParseAndExecute( + group, + id, + "htmx", + []string{"htmx", "pages/error"}, + []string{"components"}, + data, + ) + require.NoError(t, err) + require.NotNil(t, buf) + assert.Contains(t, buf.String(), "Please try again") +}