From 006d799d2fe5f081c188f95a8590c4b75a93caae Mon Sep 17 00:00:00 2001 From: 杨宇千 Date: Mon, 28 Oct 2019 23:35:00 +0800 Subject: Add UserDetailController unit tests. --- .../Controllers/UserDetailControllerTest.cs | 93 ++++++++++++++ .../Helpers/AssertionResponseExtensions.cs | 141 --------------------- Timeline.Tests/Helpers/ParameterInfoAssertions.cs | 63 +++++++++ Timeline.Tests/Helpers/ReflectionHelper.cs | 13 ++ Timeline.Tests/Helpers/ResponseAssertions.cs | 141 +++++++++++++++++++++ Timeline/Controllers/UserDetailController.cs | 44 +++++++ Timeline/Filters/User.cs | 42 ++++++ Timeline/Resources/Filters.Designer.cs | 9 ++ Timeline/Resources/Filters.resx | 3 + Timeline/Resources/Filters.zh.resx | 3 + 10 files changed, 411 insertions(+), 141 deletions(-) create mode 100644 Timeline.Tests/Controllers/UserDetailControllerTest.cs delete mode 100644 Timeline.Tests/Helpers/AssertionResponseExtensions.cs create mode 100644 Timeline.Tests/Helpers/ParameterInfoAssertions.cs create mode 100644 Timeline.Tests/Helpers/ReflectionHelper.cs create mode 100644 Timeline.Tests/Helpers/ResponseAssertions.cs create mode 100644 Timeline/Controllers/UserDetailController.cs create mode 100644 Timeline/Filters/User.cs diff --git a/Timeline.Tests/Controllers/UserDetailControllerTest.cs b/Timeline.Tests/Controllers/UserDetailControllerTest.cs new file mode 100644 index 00000000..99341c40 --- /dev/null +++ b/Timeline.Tests/Controllers/UserDetailControllerTest.cs @@ -0,0 +1,93 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Mvc; +using Moq; +using System; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; +using Timeline.Controllers; +using Timeline.Filters; +using Timeline.Models.Validation; +using Timeline.Services; +using Timeline.Tests.Helpers; +using Xunit; + +namespace Timeline.Tests.Controllers +{ + public class UserDetailControllerTest : IDisposable + { + private readonly Mock _mockUserDetailService; + private readonly UserDetailController _controller; + + public UserDetailControllerTest() + { + _mockUserDetailService = new Mock(); + _controller = new UserDetailController(_mockUserDetailService.Object); + } + + public void Dispose() + { + _controller.Dispose(); + } + + [Fact] + public void AttributeTest() + { + typeof(UserDetailController).Should().BeDecoratedWith(); + + var getNickname = typeof(UserDetailController).GetMethod(nameof(UserDetailController.GetNickname)); + getNickname.Should().BeDecoratedWith() + .And.BeDecoratedWith(); + getNickname.GetParameter("username").Should().BeDecoratedWith() + .And.BeDecoratedWith(); + + var putNickname = typeof(UserDetailController).GetMethod(nameof(UserDetailController.PutNickname)); + putNickname.Should().BeDecoratedWith() + .And.BeDecoratedWith(); + putNickname.GetParameter("username").Should().BeDecoratedWith() + .And.BeDecoratedWith(); + var stringLengthAttributeOnPutBody = putNickname.GetParameter("body").Should().BeDecoratedWith() + .And.BeDecoratedWith() + .Which; + stringLengthAttributeOnPutBody.MinimumLength.Should().Be(1); + stringLengthAttributeOnPutBody.MaximumLength.Should().Be(10); + + var deleteNickname = typeof(UserDetailController).GetMethod(nameof(UserDetailController.DeleteNickname)); + deleteNickname.Should().BeDecoratedWith() + .And.BeDecoratedWith(); + deleteNickname.GetParameter("username").Should().BeDecoratedWith() + .And.BeDecoratedWith(); + } + + [Fact] + public async Task GetNickname_ShouldWork() + { + const string username = "uuu"; + const string nickname = "nnn"; + _mockUserDetailService.Setup(s => s.GetNickname(username)).ReturnsAsync(nickname); + var actionResult = await _controller.GetNickname(username); + actionResult.Result.Should().BeAssignableTo(nickname); + _mockUserDetailService.VerifyAll(); + } + + [Fact] + public async Task PutNickname_ShouldWork() + { + const string username = "uuu"; + const string nickname = "nnn"; + _mockUserDetailService.Setup(s => s.SetNickname(username, nickname)).Returns(Task.CompletedTask); + var actionResult = await _controller.PutNickname(username, nickname); + actionResult.Should().BeAssignableTo(); + _mockUserDetailService.VerifyAll(); + } + + [Fact] + public async Task DeleteNickname_ShouldWork() + { + const string username = "uuu"; + _mockUserDetailService.Setup(s => s.SetNickname(username, null)).Returns(Task.CompletedTask); + var actionResult = await _controller.DeleteNickname(username); + actionResult.Should().BeAssignableTo(); + _mockUserDetailService.VerifyAll(); + } + } +} diff --git a/Timeline.Tests/Helpers/AssertionResponseExtensions.cs b/Timeline.Tests/Helpers/AssertionResponseExtensions.cs deleted file mode 100644 index 08f10b2b..00000000 --- a/Timeline.Tests/Helpers/AssertionResponseExtensions.cs +++ /dev/null @@ -1,141 +0,0 @@ -using FluentAssertions; -using FluentAssertions.Execution; -using FluentAssertions.Formatting; -using FluentAssertions.Primitives; -using Newtonsoft.Json; -using System; -using System.Net; -using System.Net.Http; -using System.Text; -using Timeline.Models.Http; - -namespace Timeline.Tests.Helpers -{ - public class HttpResponseMessageValueFormatter : IValueFormatter - { - public bool CanHandle(object value) - { - return value is HttpResponseMessage; - } - - public string Format(object value, FormattingContext context, FormatChild formatChild) - { - string newline = context.UseLineBreaks ? Environment.NewLine : ""; - string padding = new string('\t', context.Depth); - - var res = (HttpResponseMessage)value; - - var builder = new StringBuilder(); - builder.Append($"{newline}{padding} Status Code: {res.StatusCode} ; Body: "); - - try - { - var body = res.Content.ReadAsStringAsync().Result; - if (body.Length > 40) - { - body = body[0..40] + " ..."; - } - builder.Append(body); - } - catch (AggregateException) - { - builder.Append("NOT A STRING."); - } - - return builder.ToString(); - } - } - - public class HttpResponseMessageAssertions - : ReferenceTypeAssertions - { - static HttpResponseMessageAssertions() - { - Formatter.AddFormatter(new HttpResponseMessageValueFormatter()); - } - - public HttpResponseMessageAssertions(HttpResponseMessage instance) - { - Subject = instance; - } - - protected override string Identifier => "HttpResponseMessage"; - - public AndConstraint HaveStatusCode(int expected, string because = "", params object[] becauseArgs) - { - return HaveStatusCode((HttpStatusCode)expected, because, becauseArgs); - } - - public AndConstraint HaveStatusCode(HttpStatusCode expected, string because = "", params object[] becauseArgs) - { - Execute.Assertion.BecauseOf(because, becauseArgs) - .ForCondition(Subject.StatusCode == expected) - .FailWith("Expected status code of {context:HttpResponseMessage} to be {0}{reason}, but found {1}.", expected, Subject.StatusCode); - return new AndConstraint(Subject); - } - - public AndWhichConstraint HaveJsonBody(string because = "", params object[] becauseArgs) - { - var a = Execute.Assertion.BecauseOf(because, becauseArgs); - string body; - try - { - body = Subject.Content.ReadAsStringAsync().Result; - } - catch (AggregateException e) - { - a.FailWith("Expected response body of {context:HttpResponseMessage} to be json string{reason}, but failed to read it or it was not a string. Exception is {0}.", e.InnerExceptions); - return new AndWhichConstraint(Subject, null); - } - - var result = JsonConvert.DeserializeObject(body); - return new AndWhichConstraint(Subject, result); - } - } - - public static class AssertionResponseExtensions - { - public static HttpResponseMessageAssertions Should(this HttpResponseMessage instance) - { - return new HttpResponseMessageAssertions(instance); - } - - public static AndWhichConstraint HaveCommonBody(this HttpResponseMessageAssertions assertions, string because = "", params object[] becauseArgs) - { - return assertions.HaveJsonBody(because, becauseArgs); - } - - public static AndWhichConstraint> HaveCommonDataBody(this HttpResponseMessageAssertions assertions, string because = "", params object[] becauseArgs) - { - return assertions.HaveJsonBody>(because, becauseArgs); - } - - public static void BePut(this HttpResponseMessageAssertions assertions, bool create, string because = "", params object[] becauseArgs) - { - var body = assertions.HaveStatusCode(create ? 201 : 200, because, becauseArgs) - .And.Should().HaveJsonBody(because, becauseArgs) - .Which; - body.Code.Should().Be(0); - body.Data.Create.Should().Be(create); - } - - public static void BeDelete(this HttpResponseMessageAssertions assertions, bool delete, string because = "", params object[] becauseArgs) - { - var body = assertions.HaveStatusCode(200, because, becauseArgs) - .And.Should().HaveJsonBody(because, becauseArgs) - .Which; - body.Code.Should().Be(0); - body.Data.Delete.Should().Be(delete); - } - - public static void BeInvalidModel(this HttpResponseMessageAssertions assertions, string message = null) - { - message = string.IsNullOrEmpty(message) ? "" : ", " + message; - assertions.HaveStatusCode(400, "Invalid Model Error must have 400 status code{0}", message) - .And.Should().HaveCommonBody("Invalid Model Error must have CommonResponse body{0}", message) - .Which.Code.Should().Be(ErrorCodes.Http.Common.InvalidModel, - "Invalid Model Error must have code {0} in body{1}", - ErrorCodes.Http.Common.InvalidModel, message); - } - } -} diff --git a/Timeline.Tests/Helpers/ParameterInfoAssertions.cs b/Timeline.Tests/Helpers/ParameterInfoAssertions.cs new file mode 100644 index 00000000..e3becee1 --- /dev/null +++ b/Timeline.Tests/Helpers/ParameterInfoAssertions.cs @@ -0,0 +1,63 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using FluentAssertions.Formatting; +using FluentAssertions.Primitives; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +namespace Timeline.Tests.Helpers +{ + public class ParameterInfoValueFormatter : IValueFormatter + { + public bool CanHandle(object value) + { + return value is ParameterInfo; + } + + public string Format(object value, FormattingContext context, FormatChild formatChild) + { + var param = (ParameterInfo)value; + return $"{param.Member.DeclaringType.FullName}.{param.Member.Name}#{param.Name}"; + } + } + + public class ParameterInfoAssertions : ReferenceTypeAssertions + { + static ParameterInfoAssertions() + { + Formatter.AddFormatter(new ParameterInfoValueFormatter()); + } + + public ParameterInfoAssertions(ParameterInfo parameterInfo) + { + Subject = parameterInfo; + } + + protected override string Identifier => "parameter"; + + public AndWhichConstraint BeDecoratedWith(string because = "", params object[] becauseArgs) + where TAttribute : Attribute + { + var attribute = Subject.GetCustomAttribute(false); + + Execute.Assertion + .BecauseOf(because, becauseArgs) + .ForCondition(attribute != null) + .FailWith("Expected {0} {1} to be decorated with {2}{reason}, but that attribute was not found.", + Identifier, Subject, typeof(TAttribute).FullName); + + return new AndWhichConstraint(this, attribute); + } + } + + public static class ParameterInfoAssertionExtensions + { + public static ParameterInfoAssertions Should(this ParameterInfo parameterInfo) + { + return new ParameterInfoAssertions(parameterInfo); + } + } +} diff --git a/Timeline.Tests/Helpers/ReflectionHelper.cs b/Timeline.Tests/Helpers/ReflectionHelper.cs new file mode 100644 index 00000000..3f6036e3 --- /dev/null +++ b/Timeline.Tests/Helpers/ReflectionHelper.cs @@ -0,0 +1,13 @@ +using System.Linq; +using System.Reflection; + +namespace Timeline.Tests.Helpers +{ + public static class ReflectionHelper + { + public static ParameterInfo GetParameter(this MethodInfo methodInfo, string name) + { + return methodInfo.GetParameters().Where(p => p.Name == name).Single(); + } + } +} diff --git a/Timeline.Tests/Helpers/ResponseAssertions.cs b/Timeline.Tests/Helpers/ResponseAssertions.cs new file mode 100644 index 00000000..08f10b2b --- /dev/null +++ b/Timeline.Tests/Helpers/ResponseAssertions.cs @@ -0,0 +1,141 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using FluentAssertions.Formatting; +using FluentAssertions.Primitives; +using Newtonsoft.Json; +using System; +using System.Net; +using System.Net.Http; +using System.Text; +using Timeline.Models.Http; + +namespace Timeline.Tests.Helpers +{ + public class HttpResponseMessageValueFormatter : IValueFormatter + { + public bool CanHandle(object value) + { + return value is HttpResponseMessage; + } + + public string Format(object value, FormattingContext context, FormatChild formatChild) + { + string newline = context.UseLineBreaks ? Environment.NewLine : ""; + string padding = new string('\t', context.Depth); + + var res = (HttpResponseMessage)value; + + var builder = new StringBuilder(); + builder.Append($"{newline}{padding} Status Code: {res.StatusCode} ; Body: "); + + try + { + var body = res.Content.ReadAsStringAsync().Result; + if (body.Length > 40) + { + body = body[0..40] + " ..."; + } + builder.Append(body); + } + catch (AggregateException) + { + builder.Append("NOT A STRING."); + } + + return builder.ToString(); + } + } + + public class HttpResponseMessageAssertions + : ReferenceTypeAssertions + { + static HttpResponseMessageAssertions() + { + Formatter.AddFormatter(new HttpResponseMessageValueFormatter()); + } + + public HttpResponseMessageAssertions(HttpResponseMessage instance) + { + Subject = instance; + } + + protected override string Identifier => "HttpResponseMessage"; + + public AndConstraint HaveStatusCode(int expected, string because = "", params object[] becauseArgs) + { + return HaveStatusCode((HttpStatusCode)expected, because, becauseArgs); + } + + public AndConstraint HaveStatusCode(HttpStatusCode expected, string because = "", params object[] becauseArgs) + { + Execute.Assertion.BecauseOf(because, becauseArgs) + .ForCondition(Subject.StatusCode == expected) + .FailWith("Expected status code of {context:HttpResponseMessage} to be {0}{reason}, but found {1}.", expected, Subject.StatusCode); + return new AndConstraint(Subject); + } + + public AndWhichConstraint HaveJsonBody(string because = "", params object[] becauseArgs) + { + var a = Execute.Assertion.BecauseOf(because, becauseArgs); + string body; + try + { + body = Subject.Content.ReadAsStringAsync().Result; + } + catch (AggregateException e) + { + a.FailWith("Expected response body of {context:HttpResponseMessage} to be json string{reason}, but failed to read it or it was not a string. Exception is {0}.", e.InnerExceptions); + return new AndWhichConstraint(Subject, null); + } + + var result = JsonConvert.DeserializeObject(body); + return new AndWhichConstraint(Subject, result); + } + } + + public static class AssertionResponseExtensions + { + public static HttpResponseMessageAssertions Should(this HttpResponseMessage instance) + { + return new HttpResponseMessageAssertions(instance); + } + + public static AndWhichConstraint HaveCommonBody(this HttpResponseMessageAssertions assertions, string because = "", params object[] becauseArgs) + { + return assertions.HaveJsonBody(because, becauseArgs); + } + + public static AndWhichConstraint> HaveCommonDataBody(this HttpResponseMessageAssertions assertions, string because = "", params object[] becauseArgs) + { + return assertions.HaveJsonBody>(because, becauseArgs); + } + + public static void BePut(this HttpResponseMessageAssertions assertions, bool create, string because = "", params object[] becauseArgs) + { + var body = assertions.HaveStatusCode(create ? 201 : 200, because, becauseArgs) + .And.Should().HaveJsonBody(because, becauseArgs) + .Which; + body.Code.Should().Be(0); + body.Data.Create.Should().Be(create); + } + + public static void BeDelete(this HttpResponseMessageAssertions assertions, bool delete, string because = "", params object[] becauseArgs) + { + var body = assertions.HaveStatusCode(200, because, becauseArgs) + .And.Should().HaveJsonBody(because, becauseArgs) + .Which; + body.Code.Should().Be(0); + body.Data.Delete.Should().Be(delete); + } + + public static void BeInvalidModel(this HttpResponseMessageAssertions assertions, string message = null) + { + message = string.IsNullOrEmpty(message) ? "" : ", " + message; + assertions.HaveStatusCode(400, "Invalid Model Error must have 400 status code{0}", message) + .And.Should().HaveCommonBody("Invalid Model Error must have CommonResponse body{0}", message) + .Which.Code.Should().Be(ErrorCodes.Http.Common.InvalidModel, + "Invalid Model Error must have code {0} in body{1}", + ErrorCodes.Http.Common.InvalidModel, message); + } + } +} diff --git a/Timeline/Controllers/UserDetailController.cs b/Timeline/Controllers/UserDetailController.cs new file mode 100644 index 00000000..ef13b462 --- /dev/null +++ b/Timeline/Controllers/UserDetailController.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Mvc; +using System.Threading.Tasks; +using Timeline.Filters; +using Timeline.Models.Validation; +using Timeline.Services; +using System.ComponentModel.DataAnnotations; + +namespace Timeline.Controllers +{ + [ApiController] + public class UserDetailController : Controller + { + private readonly IUserDetailService _service; + + public UserDetailController(IUserDetailService service) + { + _service = service; + } + + [HttpGet("users/{username}/nickname")] + [CatchUserNotExistException] + public async Task> GetNickname([FromRoute][Username] string username) + { + return Ok(await _service.GetNickname(username)); + } + + [HttpPut("users/{username}/nickname")] + [CatchUserNotExistException] + public async Task PutNickname([FromRoute][Username] string username, + [FromBody][StringLength(10, MinimumLength = 1)] string body) + { + await _service.SetNickname(username, body); + return Ok(); + } + + [HttpDelete("users/{username}/nickname")] + [CatchUserNotExistException] + public async Task DeleteNickname([FromRoute][Username] string username) + { + await _service.SetNickname(username, null); + return Ok(); + } + } +} diff --git a/Timeline/Filters/User.cs b/Timeline/Filters/User.cs new file mode 100644 index 00000000..22fae938 --- /dev/null +++ b/Timeline/Filters/User.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using System; +using Timeline.Models.Http; + +namespace Timeline +{ + public static partial class ErrorCodes + { + public static partial class Http + { + public static partial class Filter // bxx = 1xx + { + public static class User // bbb = 101 + { + public const int NotExist = 11010001; + } + + } + } + } +} + +namespace Timeline.Filters +{ + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] + public class CatchUserNotExistExceptionAttribute : ExceptionFilterAttribute + { + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1062:Validate arguments of public methods", Justification = "ASP.Net already checked.")] + public override void OnException(ExceptionContext context) + { + var body = new CommonResponse( + ErrorCodes.Http.Filter.User.NotExist, + Resources.Filters.MessageUserNotExist); + + if (context.HttpContext.Request.Method == "GET") + context.Result = new NotFoundObjectResult(body); + else + context.Result = new BadRequestObjectResult(body); + } + } +} diff --git a/Timeline/Resources/Filters.Designer.cs b/Timeline/Resources/Filters.Designer.cs index ae3565f7..e3c8be41 100644 --- a/Timeline/Resources/Filters.Designer.cs +++ b/Timeline/Resources/Filters.Designer.cs @@ -86,5 +86,14 @@ namespace Timeline.Resources { return ResourceManager.GetString("MessageHeaderContentTypeMissing", resourceCulture); } } + + /// + /// Looks up a localized string similar to The user does not exist.. + /// + internal static string MessageUserNotExist { + get { + return ResourceManager.GetString("MessageUserNotExist", resourceCulture); + } + } } } diff --git a/Timeline/Resources/Filters.resx b/Timeline/Resources/Filters.resx index d2b7e68a..ba1fcee8 100644 --- a/Timeline/Resources/Filters.resx +++ b/Timeline/Resources/Filters.resx @@ -126,4 +126,7 @@ Header Content-Type is required. + + The user does not exist. + \ No newline at end of file diff --git a/Timeline/Resources/Filters.zh.resx b/Timeline/Resources/Filters.zh.resx index 90e97e49..690a3e39 100644 --- a/Timeline/Resources/Filters.zh.resx +++ b/Timeline/Resources/Filters.zh.resx @@ -126,4 +126,7 @@ 缺少必需的请求头Content-Type。 + + 用户不存在。 + \ No newline at end of file -- cgit v1.2.3