/* Copyright 2005-2006 Tim Fennell * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package net.sourceforge.stripes.mock; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.SortedMap; import java.util.TreeMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import javax.servlet.Filter; import net.sourceforge.stripes.action.ActionBean; import net.sourceforge.stripes.action.Message; import net.sourceforge.stripes.controller.ActionResolver; import net.sourceforge.stripes.controller.AnnotatedClassActionResolver; import net.sourceforge.stripes.controller.StripesConstants; import net.sourceforge.stripes.controller.StripesFilter; import net.sourceforge.stripes.controller.UrlBindingFactory; import net.sourceforge.stripes.util.CryptoUtil; import net.sourceforge.stripes.validation.ValidationErrors; /** * <p>Mock object that attempts to make it easier to use the other Mock objects in this package * to interact with Stripes and to interrogate the results. Everything that is done in this class * is do-able without this class! It simply exists to make things a bit easier. As a result all * the methods in this class simply manipulate one or more of the underlying Mock objects. If * some needed capability is not exposed through the MockRoundtrip it is always possible to fetch * the underlying request, response and context and interact with them directly.</p> * * <p>It is worth noting that the Mock system <b>does not process forwards, includes and * redirects</b>. When an ActionBean (or other object) invokes the servlet APIs for any of these * actions it is recorded so that it can be reported and verified later. In the majority of cases * it should be sufficient to test ActionBeans in isolation and verify that they produced the * expected output data and/or forward/redirect. If your ActionBeans depend on being able to include * other resources before continuing, sorry - you're on your own!</p> * * <p>An example usage of this class might look like:</p> * * <pre> * MockServletContext context = ...; * MockRoundtrip trip = new MockRoundtrip(context, CalculatorActionBean.class); * trip.setParameter("numberOne", "2"); * trip.setParameter("numberTwo", "2"); * trip.execute(); * CalculatorActionBean bean = trip.getActionBean(CalculatorActionBean.class); * Assert.assertEquals(bean.getResult(), 4, "two plus two should equal four"); * Assert.assertEquals(trip.getDestination(), ""/quickstart/index.jsp"); * </pre> * * @author Tim Fennell * @since Stripes 1.1.1 */ public class MockRoundtrip { /** Default value for the source page that generated this round trip request. */ public static final String DEFAULT_SOURCE_PAGE = "_default_source_page_"; private MockHttpServletRequest request; private MockHttpServletResponse response; private MockServletContext context; /** * Preferred constructor that will manufacture a request. Uses the ServletContext to ensure * that the request's context path matches. Pulls the UrlBinding of the ActionBean and uses * that as the requst URL. Constructs a new session for the request. * * @param context the MockServletContext that will receive this request * @param beanType a Class object representing the ActionBean that should receive the request */ public MockRoundtrip(MockServletContext context, Class<? extends ActionBean> beanType) { this(context, beanType, new MockHttpSession(context) ); } /** * Preferred constructor that will manufacture a request. Uses the ServletContext to ensure * that the request's context path matches. Pulls the UrlBinding of the ActionBean and uses * that as the requst URL. Constructs a new session for the request. * * @param context the MockServletContext that will receive this request * @param beanType a Class object representing the ActionBean that should receive the request */ public MockRoundtrip(MockServletContext context, Class<? extends ActionBean> beanType, MockHttpSession session) { this(context, getUrlBindingStub(beanType, context), session); } /** * Constructor that will create a request suitable for the provided servlet context and * URL. Note that in general the constructors that take an ActionBean Class object are preferred * over those that take a URL. Constructs a new session for the request. * * @param context the MockServletContext that will receive this request * @param actionBeanUrl the url binding of the action bean */ public MockRoundtrip(MockServletContext context, String actionBeanUrl) { this(context, actionBeanUrl, new MockHttpSession(context)); } /** * Constructor that will create a request suitable for the provided servlet context and * URL. Note that in general the contructors that take an ActionBean Class object are preferred * over those that take a URL. The request will use the provided session instead of creating * a new one. * * @param context the MockServletContext that will receive this request * @param actionBeanUrl the url binding of the action bean * @param session an instance of MockHttpSession to use for the request */ public MockRoundtrip(MockServletContext context, String actionBeanUrl, MockHttpSession session) { // Look for a query string and parse out the parameters if one is present String path = actionBeanUrl; SortedMap<String, List<String>> parameters = null; int qmark = actionBeanUrl.indexOf("?"); if (qmark > 0) { path = actionBeanUrl.substring(0, qmark); if (qmark < actionBeanUrl.length()) { String query = actionBeanUrl.substring(qmark + 1); if (query != null && query.length() > 0) { parameters = new TreeMap<String, List<String>>(); for (String kv : query.split("&")) { String[] parts = kv.split("="); String key, value; if (parts.length == 1) { key = parts[0]; value = null; } else if (parts.length == 2) { key = parts[0]; value = parts[1]; } else { key = value = null; } if (key != null) { List<String> values = parameters.get(key); if (values == null) values = new ArrayList<String>(); values.add(value); parameters.put(key, values); } } } } } this.context = context; this.request = new MockHttpServletRequest("/" + context.getServletContextName(), path); this.request.setSession(session); this.response = new MockHttpServletResponse(); setSourcePage(DEFAULT_SOURCE_PAGE); // Add any parameters that were embedded in the given URL if (parameters != null) { for (Map.Entry<String, List<String>> entry : parameters.entrySet()) { for (String value : entry.getValue()) { addParameter(entry.getKey(), value); } } } } /** Get the servlet request object to be used by this round trip */ public MockHttpServletRequest getRequest() { return request; } /** Set the servlet request object to be used by this round trip */ protected void setRequest(MockHttpServletRequest request) { this.request = request; } /** Get the servlet response object to be used by this round trip */ public MockHttpServletResponse getResponse() { return response; } /** Set the servlet response object to be used by this round trip */ protected void setResponse(MockHttpServletResponse response) { this.response = response; } /** Get the ActionBean context to be used by this round trip */ public MockServletContext getContext() { return context; } /** Set the ActionBean context to be used by this round trip */ protected void setContext(MockServletContext context) { this.context = context; } /** * Sets the named request parameter to the value or values provided. Any existing values are * wiped out and replaced with the value(s) provided. */ public void setParameter(String name, String... value) { this.request.getParameterMap().put(name, value); } /** * Adds the value provided to the set of values for the named request parameter. If one or * more values already exist they will be retained, and the new value will be appended to the * set of values. */ public void addParameter(String name, String... value) { if (this.request.getParameterValues(name) == null) { setParameter(name, value); } else { String[] oldValues = this.request.getParameterMap().get(name); String[] combined = new String[oldValues.length + value.length]; System.arraycopy(oldValues, 0, combined, 0, oldValues.length); System.arraycopy(value, 0, combined, oldValues.length, value.length); setParameter(name, combined); } } /** * All requests to Stripes that can generate validation errors are required to supply a * request parameter telling Stripes where the request came from. If you do not supply a * value for this parameter then the value of MockRoundTrip.DEFAULT_SOURCE_PAGE will be used. */ public void setSourcePage(String url) { if (url != null) { url = CryptoUtil.encrypt(url); } setParameter(StripesConstants.URL_KEY_SOURCE_PAGE, url); } /** * Executes the request in the servlet context that was provided in the constructor. If the * request throws an Exception then that will be thrown from this method. Otherwise, once the * execution has completed you can use the other methods on this class to examine the outcome. */ public void execute() throws Exception { this.context.acceptRequest(this.request, this.response); } /** * Executes the request in the servlet context that was provided in the constructor. Sets up * the request so that it mimics the submission of a specific event, named by the 'event' * parameter to this method. If the request throws an Exception then that will be thrown from * this method. Otherwise, once the execution has completed you can use the other methods on * this class to examine the outcome. */ public void execute(String event) throws Exception { setParameter(event, ""); execute(); } /** * Gets the instance of the ActionBean type provided that was instantiated by Stripes to * handle the request. If a bean of this type was not instantiated, this method will * return null. * * @param type the Class object representing the ActionBean type expected * @return the instance of the ActionBean that was created by Stripes */ @SuppressWarnings("unchecked") public <A extends ActionBean> A getActionBean(Class<A> type) { A bean = (A) this.request.getAttribute(getUrlBinding(type, this.context)); if (bean == null) { bean = (A) this.request.getSession().getAttribute(getUrlBinding(type, this.context)); } return bean; } /** * Gets the (potentially empty) set of Validation Errors that were produced by the request. */ public ValidationErrors getValidationErrors() { ActionBean bean = (ActionBean) this.request.getAttribute(StripesConstants.REQ_ATTR_ACTION_BEAN); return bean.getContext().getValidationErrors(); } /** * Gets the {@link List} of {@link Message}s that were produced by the request. * This should be used instead of obtaining the messages from the * {@link net.sourceforge.stripes.action.ActionBeanContext} as the context is bound to the * {@link net.sourceforge.stripes.controller.FlashScope}. * * @return */ public List<Message> getMessages() { Object attribute = this.request.getAttribute(StripesConstants.REQ_ATTR_MESSAGES); if (attribute == null) { return null; } return (List<Message>) attribute; } /** * Gets, as bytes, any data that was written to the output stream associated with the * request. Note that since the Mock system does not write standard HTTP response information * (headers etc.) to the output stream, this will be exactly what was written by the * ActionBean. */ public byte[] getOutputBytes() { return this.response.getOutputBytes(); } /** * Gets, as a String, any data that was written to the output stream associated with the * request. Note that since the Mock system does not write standard HTTP response information * (headers etc.) to the output stream, this will be exactly what was written by the * ActionBean. */ public String getOutputString() { return this.response.getOutputString(); } /** * Gets the URL to which Stripes was directed after invoking the ActionBean. Assumes that * the request was either forwarded or redirected exactly once. If the request was forwarded * then the forwarded URL will be returned verbatim. If the response was redirected and the * redirect URL was within the same web application, then the URL returned will exclude the * context path. I.e. the URL returned will be the same regardless of whether the page was * forwarded to or redirected to. */ public String getDestination() { String forward = this.request.getForwardUrl(); String redirect = this.response.getRedirectUrl(); if (forward != null) { return forward; } else if (redirect != null) { String contextPath = this.request.getContextPath(); if (contextPath.length() > 1 && redirect.startsWith(contextPath + '/')) redirect = redirect.substring(contextPath.length()); } return redirect; } /** If the request resulted in a forward, returns the URL that was forwarded to. */ public String getForwardUrl() { return this.request.getForwardUrl(); } /** * If the request resulted in a redirect, returns the URL that was redirected to. Unlike * getDestination(), the URL in this case will be the exact URL that would have been sent to * the browser (i.e. including the servlet context). */ public String getRedirectUrl() { return this.response.getRedirectUrl(); } /** Find and return the {@link AnnotatedClassActionResolver} for the given context. */ private static AnnotatedClassActionResolver getActionResolver(MockServletContext context) { for (Filter filter : context.getFilters()) { if (filter instanceof StripesFilter) { ActionResolver resolver = ((StripesFilter) filter).getInstanceConfiguration() .getActionResolver(); if (resolver instanceof AnnotatedClassActionResolver) { return (AnnotatedClassActionResolver) resolver; } } } return null; } /** Find and return the {@link UrlBindingFactory} for the given context. */ private static UrlBindingFactory getUrlBindingFactory(MockServletContext context) { ActionResolver resolver = getActionResolver(context); if (resolver instanceof AnnotatedClassActionResolver) { return ((AnnotatedClassActionResolver) resolver).getUrlBindingFactory(); } return null; } /** * A helper method that fetches the UrlBinding of a class in the manner it would be interpreted * by the current context configuration. */ private static String getUrlBinding(Class<? extends ActionBean> clazz, MockServletContext context) { return getActionResolver(context).getUrlBinding(clazz); } /** Get the URL binding for an {@link ActionBean} class up to the first parameter. */ private static String getUrlBindingStub(Class<? extends ActionBean> clazz, MockServletContext context) { return getUrlBindingFactory(context).getBindingPrototype(clazz).getPath(); } }