/* vim: set ts=2 et sw=2 cindent fo=qroca: */ package com.globant.katari.tools; import static org.easymock.EasyMock.*; import java.io.File; import java.io.FileOutputStream; import java.io.StringWriter; import java.io.Writer; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.HashMap; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.servlet.http.HttpServletRequest; import junit.framework.Assert; import org.apache.commons.lang.Validate; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.web.context.support.GenericWebApplicationContext; import org.springframework.web.servlet.DispatcherServlet; import org.springframework.web.servlet.LocaleResolver; import org.springframework.web.servlet.i18n.FixedLocaleResolver; import org.springframework.web.servlet.support.RequestContext; import org.springframework.web.util.WebUtils; import org.springframework.mock.web.MockServletContext; import freemarker.cache.ClassTemplateLoader; import freemarker.cache.MultiTemplateLoader; import freemarker.cache.TemplateLoader; import freemarker.template.Configuration; import freemarker.template.DefaultObjectWrapper; import freemarker.template.Template; /** Engine to test a freemarker template through unit test. * * This class makes it possible to test a freemarker template without starting * an application container.<br> * * The basic usage consists of creating an instance of this class with the path * where the test template will be loaded and the model map (a map of variables * that will be made available to the temaplate). Then call runAndValidate with * the template name and two optional lists of regular expresions. This * operation returns the processed template.<br> * * If a validation error occurs, runAndValidate throws a junit assertion * error.<br> * * For convenience, runAndValidate also saves the output of the template to a * file in target/freemarker-test directory. The name of this file is derived * from the call stack: it looks for the class and method that called * runAndValidate to generate the file name.<br> * * This class also supports the spring.ftl and katari.ftl macro libraries, and * spring binding errors. To add binding errors to be used in the template * do:<br> * * <pre> * * BeanPropertyBindingResult result; * * result = new BeanPropertyBindingResult(command, "command"); * * result.addError(new ObjectError("command.profile.name", new String[]{"1"}, * null, "This is the error message for the user name")); * * model.putAll(result.getModel()); * * </pre> * * @author jose.dominguez */ public class FreemarkerTestEngine { /** The number of template loaders configured. */ private static final int DEFAULT_LOADERS = 2; /** The class logger. */ private static Logger log = LoggerFactory.getLogger( FreemarkerTestEngine.class); /** Freemarker configuration. * * It is never null. */ private Configuration cfg; /** Map containing the model. * * It is never null. */ private Map<String, Object> model = new HashMap<String, Object>(); /** Constructor. * * Builds a FreemarkerTestEngine with the default locale, Locale.ENGLISH. * * @param templatePath The path where the template to test will be loaded * from. It cannot be null. * * @param theModel The model to be processed with the template. It cannot be * null. * * @throws Exception in case of error. */ public FreemarkerTestEngine(final String templatePath, final Map<String, Object> theModel) throws Exception { this(templatePath, Locale.ENGLISH, theModel); } /** Constructor. * * @param templatePath The path where the template to test will be loaded * from. It cannot be null. * * @param locale Request locale. It cannot be null. * * @param theModel The model to be processed with the template. It cannot be * null. * * @throws Exception in case of error. */ public FreemarkerTestEngine(final String templatePath, final Locale locale, final Map<String, Object> theModel) throws Exception { this(new String [] {templatePath}, locale, theModel); } /** Constructor. * * Builds a FreemarkerTestEngine with the default locale, Locale.ENGLISH. * * @param templatePaths The paths where the template to test will be loaded * from. It is useful when a template imports other templates that are in a * different path than the one under test. It cannot be null. * * @param theModel The model to be processed with the template. It cannot be * null. * * @throws Exception in case of error. */ public FreemarkerTestEngine(final String[] templatePaths, final Map<String, Object> theModel) throws Exception { this(templatePaths, Locale.ENGLISH, theModel); } /** Constructor. * * @param templatePaths The paths where the template to test will be loaded * from. It is useful when a template imports other templates that are in a * different path than the one under test. It cannot be null. * * @param locale Request locale. It cannot be null. * * @param theModel The model to be processed with the template. It cannot be * null. * * @throws Exception in case of error. */ public FreemarkerTestEngine(final String[] templatePaths, final Locale locale, final Map<String, Object> theModel) throws Exception { Validate.notNull(templatePaths, "templatePaths cannot be null"); Validate.notNull(locale, "locale cannot be null"); Validate.notNull(theModel, "theModel cannot be null"); // Creates a Freemarker configuration cfg = new Configuration(); model.put("baseweb", "/katari"); model.putAll(theModel); // Setting loaders TemplateLoader[] allLoaders; allLoaders = new TemplateLoader[DEFAULT_LOADERS + templatePaths.length]; int i = 0; for (i = 0; i < templatePaths.length; ++i) { String path = templatePaths[i]; allLoaders[i] = new ClassTemplateLoader(this.getClass(), path); } // path for spring.ftl allLoaders[i] = new ClassTemplateLoader(this.getClass(), "/org/springframework/web/servlet/view/freemarker"); // path for katari.ftl allLoaders[i + 1] = new ClassTemplateLoader(this.getClass(), "/com/globant/katari/core/web"); cfg.setTemplateLoader(new MultiTemplateLoader(allLoaders)); HttpServletRequest mockServletRequest = buildMockServletRequest(locale); cfg.setSharedVariable("springMacroRequestContext", new RequestContext(mockServletRequest, model)); cfg.setSharedVariable("request", mockServletRequest); cfg.setObjectWrapper(new DefaultObjectWrapper()); } /** Processes a template and validates it with regular expressions. * * If one of the regular expressions fails to match, this method throws an * AssertionFailedError, in other words, it uses junit assertions to check * for the regular expressions. * * @param templateName The name of the template to test. It cannot be null. * * @param valid List of regular expressions to match against the result of * processing the template. It cannot be null. * * @param invalid List of regular expressions that must not match against the * result of processing the template. It cannot be null. * * @return Returns a string with the result of expanding the template. * * @throws Exception in case of error. */ public String runAndValidate(final String templateName, final List<String> valid, final List<String> invalid) throws Exception { Validate.notNull(templateName, "The template name cannot be null."); Validate.notNull(valid, "The list of valid regular expressions cannot be" + " null."); Validate.notNull(valid, "The list of invalid regular expressions cannot be" + " null."); Template template = cfg.getTemplate(templateName); Writer out = new StringWriter(); template.process(model, out); String result = out.toString(); dumpResult(result); log.debug("Output of processing '" + templateName + "':\n" + result); for (String regex : valid) { Pattern pattern; pattern = Pattern.compile(regex, Pattern.MULTILINE | Pattern.DOTALL); Matcher matcher = pattern.matcher(result); Assert.assertTrue("The valid regular expression '" + regex + "' did not" + " match the template output.", matcher.matches()); } for (String regex : invalid) { Pattern pattern; pattern = Pattern.compile(regex, Pattern.MULTILINE | Pattern.DOTALL); Matcher matcher = pattern.matcher(result); Assert.assertFalse("The invalid regular expression '" + regex + "' matched the template output.", matcher.matches()); } return result; } /** Dumps the result of processing the freemarker template to a file. * * It dumps the file to target/freemarker-test directory. It builds the file * name from the provided stack trace. The file name is built with the class * name (including the package), and the name of the method. * * To choose the specific stack trace entry, this method simply finds the * call to runAndValidate method and picks the next one. This implies that * the name of the file is generated based on the method that called * runAndValidate. * * @param output The freemarker generated output. It cannot be null. * * @throws Exception in case of error. */ private void dumpResult(final String output) throws Exception { Validate.notNull(output, "The output cannot be null"); // thisName identifies the stack trace entry of the runAndValidate method. String thisName; thisName = FreemarkerTestEngine.class.getName() + ".runAndValidate.html"; // Creates the output directory if it does not exist. File directory = new File("target/freemarker-test"); directory.mkdirs(); // Iterates the stack trace of the current thread. StackTraceElement[] stack = Thread.currentThread().getStackTrace(); for (int i = 0; i < stack.length; ++i) { String name = buildOutputName(stack[i]); // Finds the runAndValidate method call in the stack trace. if (name.equals(thisName)) { // Writes the output to the file. name = buildOutputName(stack[i + 1]); FileOutputStream stream = null; try { stream = new FileOutputStream("target/freemarker-test/" + name); stream.write(output.getBytes()); } finally { if (stream != null) { stream.close(); } } } } } /** Builds an output file name base on a stack trace element. * * The name is the fully qualified class name, followed by a dot, followed by * the method name, with an '.html' extension. * * @param entry The stack trace element used to generate de file name. It * cannot be null. * * @return a string with the output file name, never returns null. */ private String buildOutputName(final StackTraceElement entry) { Validate.notNull(entry, "The stack trace entry cannot be null."); return entry.getClassName() + "." + entry.getMethodName() + ".html"; } /** * Creates a mocked HttpServletRequest. * * @param locale Request Locale, it cannot be null. * * @return the mocked HttpServletRequest. It never returns null. */ private HttpServletRequest buildMockServletRequest(final Locale locale) { Validate.notNull(locale, "locale cannot be null"); MockServletContext servletContext = new MockServletContext(); servletContext.addInitParameter(WebUtils.HTML_ESCAPE_CONTEXT_PARAM, WebUtils.HTML_ESCAPE_CONTEXT_PARAM); // Creates a Web Application Context. GenericWebApplicationContext context = new GenericWebApplicationContext(); context.setServletContext(servletContext); context.refresh(); // Creates a LocaleResolver. LocaleResolver localeResolver = new FixedLocaleResolver(); // Mocks the HttpServletRequest. HttpServletRequest servletRequest = createMock(HttpServletRequest.class); expect(servletRequest .getAttribute(DispatcherServlet.LOCALE_RESOLVER_ATTRIBUTE)) .andReturn(localeResolver); expect(servletRequest .getAttribute(RequestContext.WEB_APPLICATION_CONTEXT_ATTRIBUTE)) .andReturn(context); expect(servletRequest.getContextPath()).andReturn("testcontext/"); replay(servletRequest); return servletRequest; } }