feanz
12/11/2013 - 10:15 AM

Route Testing Extensions

Route Testing Extensions

	/// <summary>
	///     Used to simplify testing routes and restful testing routes
	///     <example>
	///         This tests that incoming PUT on resource is handled by the update method of the Banner controller
	///         "~/banner/1"
	///         .WithMethod(HttpVerbs.Put)
	///         .ShouldMapTo<BannerController>(action => action.Update(1));
	///     </example>
	/// </summary>
	public static class RouteTestingExtensions
	{
		/// <summary>
		///     Will return the name of the action specified in the ActionNameAttribute for a method if it has an
		///     ActionNameAttribute.
		///     Will return the name of the method otherwise.
		/// </summary>
		/// <param name="method"></param>
		/// <returns></returns>
		public static string ActionName(this MethodInfo method)
		{
			return method.IsDecoratedWith<ActionNameAttribute>() ? method.GetAttribute<ActionNameAttribute>().Name : method.Name;
		}

		/// <summary>
		///     Will return true the first attribute of type TAttribute on the attributeTarget.
		/// </summary>
		/// <typeparam name="TAttribute"></typeparam>
		/// <param name="attributeTarget"></param>
		/// <returns></returns>
		public static TAttribute GetAttribute<TAttribute>(this ICustomAttributeProvider attributeTarget) where TAttribute : Attribute
		{
			return (TAttribute) attributeTarget.GetCustomAttributes(typeof (TAttribute), false)[0];
		}

		#region MVC

		/// <summary>
		///     Gets a value from the <see cref="RouteValueDictionary" /> by key.  Does a
		///     case-insensitive search on the keys.
		/// </summary>
		/// <param name="routeValues"></param>
		/// <param name="key"></param>
		/// <returns></returns>
		public static object GetValue(this RouteValueDictionary routeValues, string key)
		{
			return (from routeValueKey in routeValues.Keys
				where string.Equals(routeValueKey, key, StringComparison.InvariantCultureIgnoreCase)
				select routeValues[routeValueKey] == null ? null : routeValues[routeValueKey].ToString()).FirstOrDefault();
		}

		/// <summary>
		///     Will return true if the attributeTarget is decorated with an attribute of type TAttribute.
		///     Will return false if not.
		/// </summary>
		/// <typeparam name="TAttribute"></typeparam>
		/// <param name="attributeTarget"></param>
		/// <returns></returns>
		public static bool IsDecoratedWith<TAttribute>(this ICustomAttributeProvider attributeTarget) where TAttribute : Attribute
		{
			return attributeTarget.GetCustomAttributes(typeof (TAttribute), false).Length > 0;
		}

		/// <summary>
		///     Find the route for a URL and an Http Method
		///     because you have a method contraint on the route
		/// </summary>
		/// <param name="url"></param>
		/// <param name="httpMethod"></param>
		/// <returns></returns>
		public static RouteData Route(string url, string httpMethod)
		{
			var context = new MockHttpContextFixture().WithRelativePath(url).WithHttpMethod(httpMethod).Build();
			return RouteTable.Routes.GetRouteData(context);
		}

		/// <summary>
		///     Returns the corresponding route for the URL.  Returns null if no route was found.
		/// </summary>
		/// <param name="url">The app relative url to test.</param>
		/// <returns>A matching <see cref="RouteData" />, or null.</returns>
		public static RouteData Route(this string url)
		{
			var context = new MockHttpContextFixture().WithRelativePath(url).Build();
			return RouteTable.Routes.GetRouteData(context);
		}

		/// <summary>
		///     Returns the corresponding route for the URL.  Returns null if no route was found.
		/// </summary>
		/// <param name="url">The URL.</param>
		/// <param name="httpMethod">The HTTP method.</param>
		/// <param name="formMethod">The form method.</param>
		/// <returns></returns>
		public static RouteData Route(this string url, HttpVerbs httpMethod, HttpVerbs formMethod)
		{
			var context = new MockHttpContextFixture().WithRelativePath(url).WithHttpMethod(httpMethod).Build();
			var route = RouteTable.Routes.GetRouteData(context);

			return route;
		}

		/// <summary>
		///     Returns the corresponding route for the URL.  Returns null if no route was found.
		/// </summary>
		/// <param name="url">The URL.</param>
		/// <param name="httpMethod">The HTTP method.</param>
		/// <returns></returns>
		public static RouteData Route(this string url, HttpVerbs httpMethod)
		{
			var context = new MockHttpContextFixture().WithRelativePath(url).WithHttpMethod(httpMethod).Build();
			var route = RouteTable.Routes.GetRouteData(context);

			return route;
		}

		/// <summary>
		///     Verifies the <see cref="RouteData">routeData</see> will instruct the routing engine to ignore the route.
		/// </summary>
		/// <param name="relativeUrl"></param>
		/// <returns></returns>
		public static RouteData ShouldBeIgnored(this string relativeUrl)
		{
			var routeData = relativeUrl.Route();

			if (routeData.RouteHandler.GetType() != typeof (PageRouteHandler))
				throw new RouteTestingException("Expected StopRoutingHandler, but wasn't");

			return routeData;
		}

		/// <summary>
		///     Asserts that the route matches the expression specified.  Checks controller, action, and any method arguments
		///     into the action as route values.
		/// </summary>
		/// <typeparam name="TController">The controller.</typeparam>
		/// <param name="routeData">The routeData to check</param>
		/// <param name="action">The action to call on TController.</param>
		public static RouteData ShouldMapTo<TController>(this RouteData routeData, Expression<Func<TController, ActionResult>> action)
			where TController : Controller
		{
			if (RouteTable.Routes.Count == 0)
				throw new ArgumentException("No routes found in route table make sure you register routes before testing route matching");

			if (routeData == null)
				throw new ArgumentException("The URL did not match any route");

			//check controller
			routeData.ShouldMapTo<TController>();

			//check action
			var methodCall = CheckActionMaps(routeData, action);

			CheckArgumentsMap(routeData, methodCall);

			return routeData;
		}

		/// <summary>
		///     Converts the URL to matching RouteData and verifies that it will match a route with the values specified by the
		///     expression.
		/// </summary>
		/// <typeparam name="TController">The type of controller</typeparam>
		/// <param name="relativeUrl">The ~/ based url</param>
		/// <param name="action">The expression that defines what action gets called (and with which parameters)</param>
		/// <returns></returns>
		public static RouteData ShouldMapTo<TController>(this string relativeUrl, Expression<Func<TController, ActionResult>> action) where TController : Controller
		{
			if (!relativeUrl.StartsWith("~"))
				throw new InvalidOperationException("Url should be relative and start with a ~");

			return relativeUrl.Route().ShouldMapTo(action);
		}
		
		/// <summary>
		///     Verifies the <see cref="RouteData">routeData</see> maps to the controller type specified.
		/// </summary>
		/// <typeparam name="TController"></typeparam>
		/// <param name="routeData"></param>
		/// <returns></returns>
		public static RouteData ShouldMapTo<TController>(this RouteData routeData) where TController : Controller
		{
			//strip out the word 'Controller' from the type
			var expected = typeof (TController).Name;
			if (expected.EndsWith("Controller"))
			{
				expected = expected.Substring(0, expected.LastIndexOf("Controller", StringComparison.Ordinal));
			}

			//get the key (case insensitive)
			var actual = routeData.Values.GetValue("controller").ToString();

			if (actual != expected)
				throw new RouteTestingException(string.Format("Controller names do not match expecting:{0} actual:{1}", expected, actual));

			return routeData;
		}

		/// <summary>
		///     Converts the URL to matching RouteData and verifies that it will match a route for a Web Form page.
		/// </summary>
		/// <param name="relativeUrl">The ~/ based URL</param>
		/// <param name="pathToWebForm">The ~/ based path to the web form</param>
		/// <returns></returns>
		public static RouteData ShouldMapToPage(this string relativeUrl, string pathToWebForm)
		{
			return relativeUrl.Route().ShouldMapToPage(pathToWebForm);
		}

		/// <summary>
		///     Verifies the <see cref="RouteData">routeData</see> maps to a web form page.
		/// </summary>
		public static RouteData ShouldMapToPage(this RouteData route, string pathToWebForm)
		{
			if (route.RouteHandler.GetType() != typeof (PageRouteHandler))
				throw new RouteTestingException("The route does not map to a Web Form page.");

			var handler = (PageRouteHandler) route.RouteHandler;
			if (handler.VirtualPath != pathToWebForm)
				throw new RouteTestingException("The route does not map to the correct Web Form page.");

			return route;
		}

		/// <summary>
		///     A way to start the fluent interface and and which method to use
		///     since you have a method constraint in the route.
		/// </summary>
		/// <param name="relativeUrl"></param>
		/// <param name="httpMethod"></param>
		/// <returns></returns>
		public static RouteData WithMethod(this string relativeUrl, string httpMethod)
		{
			return Route(relativeUrl, httpMethod);
		}

		/// <summary>
		///     A way to start the fluent interface and and which method to use
		///     since you have a method constraint in the route.
		/// </summary>
		/// <param name="relativeUrl"></param>
		/// <param name="verb"></param>
		/// <returns></returns>
		public static RouteData WithMethod(this string relativeUrl, HttpVerbs verb)
		{
			return WithMethod(relativeUrl, verb.ToString("g"));
		}

		/// <summary>
		///     Asserts that the route matches the expression specified based on the incoming HttpMethod and FormMethod for Simply
		///     Restful routing.  Checks controller, action, and any method arguments
		///     into the action as route values.
		/// </summary>
		/// <param name="relativeUrl">The relative URL.</param>
		/// <param name="httpMethod">The HTTP method.</param>
		/// <param name="formMethod">The form method.</param>
		/// <returns></returns>
		public static RouteData WithMethod(this string relativeUrl, HttpVerbs httpMethod, HttpVerbs formMethod)
		{
			return relativeUrl.Route(httpMethod, formMethod);
		}

		private static MethodCallExpression CheckActionMaps<TController>(RouteData routeData, Expression<Func<TController, ActionResult>> action) where TController : Controller
		{
			var methodCall = (MethodCallExpression) action.Body;
			var actualAction = routeData.Values.GetValue("action").ToString();
			var expectedAction = methodCall.Method.ActionName();

			if (!string.Equals(expectedAction, actualAction, StringComparison.InvariantCultureIgnoreCase))
				throw new RouteTestingException(string.Format("Action did not match expecting:{0} actual:{1}", expectedAction, actualAction));
			return methodCall;
		}

		private static void CheckArgumentsMap(RouteData routeData, MethodCallExpression methodCall)
		{
			for (var i = 0; i < methodCall.Arguments.Count; i++)
			{
				var param = methodCall.Method.GetParameters()[i];
				//treat strings as value types
				var isReferenceType = !param.ParameterType.IsValueType && param.ParameterType != typeof (string);
				var isNullable = isReferenceType ||
				                 (param.ParameterType.UnderlyingSystemType.IsGenericType && param.ParameterType.UnderlyingSystemType.GetGenericTypeDefinition() == typeof (Nullable<>));

				var controllerParameterName = param.Name;
				var routeDataContainsValueForParameterName = routeData.Values.ContainsKey(controllerParameterName);
				var actualValue = routeData.Values.GetValue(controllerParameterName);
				object expectedValue = null;
				var expressionToEvaluate = methodCall.Arguments[i];

				// If the parameter is nullable and the expression is a Convert UnaryExpression, 
				// we actually want to test against the value of the expression's operand.
				if (expressionToEvaluate.NodeType == ExpressionType.Convert
				    && expressionToEvaluate is UnaryExpression)
				{
					expressionToEvaluate = ((UnaryExpression) expressionToEvaluate).Operand;
				}

				switch (expressionToEvaluate.NodeType)
				{
					case ExpressionType.Constant:
						expectedValue = ((ConstantExpression) expressionToEvaluate).Value;
						break;

					case ExpressionType.New:
					case ExpressionType.MemberAccess:
						expectedValue = Expression.Lambda(expressionToEvaluate).Compile().DynamicInvoke();
						break;
					case ExpressionType.MemberInit:
						throw new InvalidOperationException("This method does not support inline implementation of method arguments");
				}

				// The parameter is nullable so an expected value of '' is equivalent to null;
				if (isNullable && actualValue == null && (string) expectedValue == string.Empty
				    || (string) actualValue == string.Empty && expectedValue == null)
				{
					continue;
				}

				// HACK: this is only sufficient while System.Web.Mvc.UrlParameter has only a single value.
				if (actualValue == UrlParameter.Optional ||
				    (actualValue != null && actualValue.ToString().Equals("System.Web.Mvc.UrlParameter")))
				{
					actualValue = null;
				}

				//if its a option param use default value 
				if (param.IsOptional)
				{
					actualValue = param.DefaultValue;
				}

				if (expectedValue is DateTime)
				{
					actualValue = Convert.ToDateTime(actualValue);
				}
				else if (isReferenceType)
				{
					CheckComplexTypeMap(routeData, expectedValue);
				}
				else
				{
					//compare as strings
					expectedValue = (expectedValue != null) ? expectedValue.ToString() : string.Empty;
					actualValue = (actualValue != null) ? actualValue.ToString() : string.Empty;
				}

				var errorMsgFmt = "Value for parameter '{0}' did not match: expected '{1}' but was '{2}'";
				if (routeDataContainsValueForParameterName)
				{
					errorMsgFmt += ".";
				}
				else
				{
					errorMsgFmt += "; no value found in the route context action parameter named '{0}' - does your matching route contain a token called '{0}'?";
				}

				if (!string.Equals((string) expectedValue, (string) actualValue, StringComparison.InvariantCultureIgnoreCase))
					throw new RouteTestingException(string.Format(errorMsgFmt, controllerParameterName, expectedValue, actualValue));
			}
		}

		private static void CheckComplexTypeMap(RouteData routeData, object expectedValue)
		{
			//if its a reference try and extract its public properties and match against route data 

			//Don't match FormCollections
			if (!(expectedValue is FormCollection))
			{
				var props = expectedValue
					.GetType()
					.GetProperties()
					.Select(x => new {x.Name, Value = x.GetValue(expectedValue, null), IsEnum = (x.PropertyType.IsEnum), Type = x.PropertyType})
					.ToList();

				if (props.Any())
				{
					foreach (var prop in props)
					{
						if (prop.Value != null)
						{
							var value = routeData.Values.GetValue(prop.Name);

							if (value != null)
							{
								if (prop.IsEnum)
								{
									//Check both value pass to enum then compare 
									if (Enum.Parse(prop.Type, prop.Value.ToString()).ToString() != Enum.Parse(prop.Type, value.ToString()).ToString())
									{
										throw new RouteTestingException("Failed to match route parameters to controller action arguments. " +
										                                "Tried to match  property: {0} on type: {1} which had a value of {2} and the route parameter {3} which had a value of {4} ", prop.Name, prop.Type, prop.Value, prop.Name, value);
									}
								}
								else if (prop.Value.ToString() != value.ToString())
								{
									throw new RouteTestingException("Failed to match route parameters to controller action arguments. " +
									                                "Tried to match  property: {0} on type: {1} which had a value of {2} and the route parameter {3} which had a value of {4} ", prop.Name, prop.Type, prop.Value, prop.Name, value);
								}
							}
						}
					}
				}
			}
		}

		#endregion

		#region WEB API

		/// <summary>
		///     Returns the corresponding http route for the URL.  Returns null if no route was found.
		/// </summary>
		/// <param name="url">The app relative url to test.</param>
		/// <param name="config"></param>
		/// <returns>A matching <see cref="RouteData" />, or null.</returns>
		public static HttpRouteTester HttpRoute(this string url, HttpConfiguration config)
		{
			return HttpRoute(url, config, HttpMethod.Get);
		}

		/// <summary>
		///     Returns the corresponding http route for the URL.  Returns null if no route was found.
		/// </summary>
		/// <param name="url">The app relative url to test.</param>
		/// <param name="config"></param>
		/// <param name="httpMethod">HttpMethod</param>
		/// <returns>A matching <see cref="RouteData" />, or null.</returns>
		public static HttpRouteTester HttpRoute(this string url, HttpConfiguration config, HttpMethod httpMethod)
		{
			var httpRequestMessage = new HttpRequestMessage(httpMethod, url);
			return new HttpRouteTester(config, httpRequestMessage);
		}

		/// <summary>
		///     Converts the URL to matching web api route and verifies that it will match a route with the values specified by the
		///     expression.
		/// </summary>
		/// <typeparam name="TController">The type of web api controller</typeparam>
		/// <param name="url">The ~/ based url</param>
		/// <param name="config"></param>
		/// <param name="action">The expression that defines what action gets called (and with which parameters)</param>
		public static HttpRouteTester ShouldMapToApi<TController>(this string url, HttpConfiguration config, Expression<Func<TController, HttpResponseMessage>> action) where TController : ApiController
		{
			url = ConvertRelativeUrlToAbsolute(url);

			return url.HttpRoute(config).ShouldMapToApi(action);
		}

		private static string ConvertRelativeUrlToAbsolute(string url)
		{
			return url.StartsWith("~") ? url.Replace("~", "http://www.site.com") : url;
		}

		/// <summary>
		///     Verifies the <see cref="RouteData">routeData</see> maps to the controller type specified.
		/// </summary>
		/// <typeparam name="TController"></typeparam>
		/// <param name="routeTester"></param>
		/// <returns></returns>
		public static HttpRouteTester ShouldMapToApi<TController>(this HttpRouteTester routeTester) where TController : ApiController
		{
			var expecting = typeof (TController);

			if (expecting != routeTester.GetControllerType())
				throw new RouteTestingException(string.Format("Controller types do not match expecting:{0} actual:{1}", expecting, routeTester.GetControllerType()));

			return routeTester;
		}

		/// <summary>
		///     Asserts that the route matches the expression specified.  Checks controller, action, and any method arguments
		///     into the action as route values.
		/// </summary>
		/// <typeparam name="TController">The controller.</typeparam>
		/// <param name="routeTester">The routeData to check</param>
		/// <param name="action">The action to call on TController.</param>
		public static HttpRouteTester ShouldMapToApi<TController>(this HttpRouteTester routeTester, Expression<Func<TController, HttpResponseMessage>> action)
			where TController : ApiController
		{
			if (routeTester == null)
				throw new ArgumentException("The URL did not match any route");

			routeTester.ShouldMapToApi<TController>();

			CheckActionMaps(routeTester, action);

			return routeTester;
		}

		private static void CheckActionMaps<TController>(HttpRouteTester routeTester, Expression<Func<TController, HttpResponseMessage>> action) where TController : ApiController
		{
			var methodCall = (MethodCallExpression) action.Body;
			var actualAction = routeTester.GetActionName();
			var expectedAction = methodCall.Method.ActionName();

			if (expectedAction != actualAction)
				throw new RouteTestingException(string.Format("Action did not match expecting:{0} actual:{1}", expectedAction, actualAction));
		}

		/// <summary>
		///     A way to start the fluent interface and and which method to use
		///     since you have a method constraint in the route.
		/// </summary>
		/// <param name="url"></param>
		/// <param name="config"></param>
		/// <param name="httpMethod"></param>
		/// <returns></returns>
		public static HttpRouteTester WithMethod(this string url, HttpConfiguration config, HttpMethod httpMethod)
		{
			return HttpRoute(url, config, httpMethod);
		}

		#endregion
	}

	public class RouteTestingException : Exception
	{
		public RouteTestingException(string message)
			: base(message)
		{
		}

		public RouteTestingException(string format, params object[] args)
			: base(string.Format(format, args))
		{
		}
	}

	public class HttpRouteTester
	{
		private readonly HttpControllerContext _controllerContext;
		private readonly IHttpControllerSelector _controllerSelector;
		private readonly HttpRequestMessage _request;

		public HttpRouteTester(HttpConfiguration config, HttpRequestMessage request)
		{
			if (config.Routes.Count == 0)
				throw new ArgumentException("No routes found in route table make sure you register routes before testing route matching");

			_request = request;

			RouteData = config.Routes.GetRouteData(request);

			if (RouteData == null)
				throw new ArgumentException("No route data found for this route");

			request.Properties[HttpPropertyKeys.HttpRouteDataKey] = RouteData;

			_controllerSelector = new DefaultHttpControllerSelector(config);
			_controllerContext = new HttpControllerContext(config, RouteData, request);
		}

		public IHttpRouteData RouteData { get; private set; }

		public string GetActionName()
		{
			if (_controllerContext.ControllerDescriptor == null)
				GetControllerType();

			var actionSelector = new ApiControllerActionSelector();

			var descriptor = actionSelector.SelectAction(_controllerContext);
			return descriptor.ActionName;
		}

		public Type GetControllerType()
		{
			var descriptor = _controllerSelector.SelectController(_request);
			_controllerContext.ControllerDescriptor = descriptor;
			return descriptor.ControllerType;
		}
	}