diff --git a/.gitignore b/.gitignore index 3e24293..66782cd 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ Bullet6/bin/ Bullet6/obj/ BlazorApp.Shared/bin/ .vs/BlazorApp/ +BlazorApp.Tests/bin/ +BlazorApp.Tests/obj/ diff --git a/BlazorApp.Tests/BlazorApp.Tests.csproj b/BlazorApp.Tests/BlazorApp.Tests.csproj new file mode 100644 index 0000000..1bbfbcf --- /dev/null +++ b/BlazorApp.Tests/BlazorApp.Tests.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + diff --git a/BlazorApp.Tests/Controllers/CustomerControllerTests.cs b/BlazorApp.Tests/Controllers/CustomerControllerTests.cs new file mode 100644 index 0000000..8f8a4cb --- /dev/null +++ b/BlazorApp.Tests/Controllers/CustomerControllerTests.cs @@ -0,0 +1,105 @@ +using BlazorApp.Controllers; +using BlazorApp.Interfaces.Services; +using BlazorApp.Shared.Models; +using BlazorApp.Shared.Models.Pagination; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; + +namespace BlazorApp.Tests.Controllers; + +public class CustomerControllerTests +{ + private readonly Mock _mockService; + private readonly CustomerController _controller; + + public CustomerControllerTests() + { + _mockService = new Mock(); + _controller = new CustomerController(_mockService.Object); + } + + [Fact] + public async Task Query_OK() + { + var customers = new PaginatedResult + { + TotalCount = 1, + Results = new List() { new Customer { Id = "blabla", CompanyName = "blabla" } } + }; + _mockService.Setup(s => s.Query(It.IsAny())).ReturnsAsync(customers); + + var actionResult = await _controller.Query(new Shared.Queries.CustomerQuery()); + + var okResult = Assert.IsType(actionResult.Result); + + var returnResult = Assert.IsType>(okResult.Value); + + Assert.Single(returnResult.Results); + Assert.Equal(1, returnResult.TotalCount); + _mockService.Verify(s => s.Query(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Get_OK() + { + var customer = new Customer + { + Id = "blabla", + CompanyName = "blabla" + }; + _mockService.Setup(s => s.Get(It.IsAny())).ReturnsAsync(customer); + + var actionResult = await _controller.Get("blabla"); + + var okResult = Assert.IsType(actionResult.Result); + + var returnResult = Assert.IsType(okResult.Value); + + Assert.NotNull(returnResult); + _mockService.Verify(s => s.Get("blabla"), Times.Once); + } + + [Fact] + public async Task Delete_ReturnsNoContent() + { + _mockService.Setup(s => s.Delete(It.IsAny())).Returns(Task.CompletedTask); + + var actionResult = await _controller.Delete("blabla"); + + Assert.IsType(actionResult); + _mockService.Verify(s => s.Delete("blabla"), Times.Once); + } + + [Fact] + public async Task Save_ReturnsNoContent() + { + var customer = new Customer + { + Id = "blabla", + CompanyName = "blabla" + }; + _mockService.Setup(s => s.Save(It.IsAny())).Returns(Task.CompletedTask); + + var actionResult = await _controller.Save(customer); + + Assert.IsType(actionResult); + _mockService.Verify(s => s.Save(customer), Times.Once); + } + + [Fact] + public async Task Update_ReturnsNoContent() + { + var customer = new Customer + { + Id = "blabla", + CompanyName = "blabla" + }; + _mockService.Setup(s => s.Update(It.IsAny())).Returns(Task.CompletedTask); + + var actionResult = await _controller.Update(customer); + + Assert.IsType(actionResult); + _mockService.Verify(s => s.Update(customer), Times.Once); + } +} \ No newline at end of file diff --git a/BlazorApp.Tests/Middlewares/ErrorHandlingMiddlewareTests.cs b/BlazorApp.Tests/Middlewares/ErrorHandlingMiddlewareTests.cs new file mode 100644 index 0000000..fd5537a --- /dev/null +++ b/BlazorApp.Tests/Middlewares/ErrorHandlingMiddlewareTests.cs @@ -0,0 +1,94 @@ +using BlazorApp.Middlewares; +using BlazorApp.Models.Exceptions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Moq; +using System.Net; +using System.Text; + +namespace BlazorApp.Tests.Middlewares +{ + public class ErrorHandlingMiddlewareTests + { + private readonly Mock> _loggerMock; + + public ErrorHandlingMiddlewareTests() + { + _loggerMock = new Mock>(); + } + + private HttpContext CreateHttpContext() + { + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + return context; + } + + private string GetResponseBody(HttpContext context) + { + context.Response.Body.Seek(0, SeekOrigin.Begin); + using var reader = new StreamReader(context.Response.Body, Encoding.UTF8); + return reader.ReadToEnd(); + } + + [Fact] + public async Task Invoke_WhenNotFoundException_Returns404() + { + var context = CreateHttpContext(); + RequestDelegate next = (ctx) => throw new NotFoundException("blabla"); + + var middleware = new ErrorHandlingMiddleware(next, _loggerMock.Object); + + await middleware.Invoke(context); + + Assert.Equal((int)HttpStatusCode.NotFound, context.Response.StatusCode); + var body = GetResponseBody(context); + Assert.Contains("blabla", body); + } + + [Fact] + public async Task Invoke_WhenBusinessException_Returns500() + { + var context = CreateHttpContext(); + RequestDelegate next = (ctx) => throw new BusinessException("blabla"); + + var middleware = new ErrorHandlingMiddleware(next, _loggerMock.Object); + + await middleware.Invoke(context); + + Assert.Equal((int)HttpStatusCode.InternalServerError, context.Response.StatusCode); + var body = GetResponseBody(context); + Assert.Contains("blabla", body); + } + + [Fact] + public async Task Invoke_WhenValidationException_Returns400() + { + var context = CreateHttpContext(); + RequestDelegate next = (ctx) => throw new ValidationException("blabla"); + + var middleware = new ErrorHandlingMiddleware(next, _loggerMock.Object); + + await middleware.Invoke(context); + + Assert.Equal((int)HttpStatusCode.BadRequest, context.Response.StatusCode); + var body = GetResponseBody(context); + Assert.Contains("blabla", body); + } + + [Fact] + public async Task Invoke_WhenUnhandledException_Returns500() + { + var context = CreateHttpContext(); + RequestDelegate next = (ctx) => throw new Exception("blabla"); + + var middleware = new ErrorHandlingMiddleware(next, _loggerMock.Object); + + await middleware.Invoke(context); + + Assert.Equal((int)HttpStatusCode.InternalServerError, context.Response.StatusCode); + var body = GetResponseBody(context); + Assert.Contains("blabla", body); + } + } +} diff --git a/BlazorApp.sln b/BlazorApp.sln index fbc8fd3..16bdd33 100644 --- a/BlazorApp.sln +++ b/BlazorApp.sln @@ -9,7 +9,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BlazorApp.Client", "BlazorA EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BlazorApp.Shared", "BlazorApp.Shared\BlazorApp.Shared.csproj", "{C6627F01-4882-40B1-890C-D70AA74B9BD1}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bullet6", "Bullet6\Bullet6.csproj", "{8FA67E47-DDAB-4712-B628-F904086B1F77}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Bullet6", "Bullet6\Bullet6.csproj", "{8FA67E47-DDAB-4712-B628-F904086B1F77}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BlazorApp.Tests", "BlazorApp.Tests\BlazorApp.Tests.csproj", "{80656871-78E1-49BF-8935-34F42BDCF6A6}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -33,6 +35,10 @@ Global {8FA67E47-DDAB-4712-B628-F904086B1F77}.Debug|Any CPU.Build.0 = Debug|Any CPU {8FA67E47-DDAB-4712-B628-F904086B1F77}.Release|Any CPU.ActiveCfg = Release|Any CPU {8FA67E47-DDAB-4712-B628-F904086B1F77}.Release|Any CPU.Build.0 = Release|Any CPU + {80656871-78E1-49BF-8935-34F42BDCF6A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {80656871-78E1-49BF-8935-34F42BDCF6A6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {80656871-78E1-49BF-8935-34F42BDCF6A6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {80656871-78E1-49BF-8935-34F42BDCF6A6}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/BlazorApp/BlazorApp.csproj b/BlazorApp/BlazorApp.csproj index c0efc44..6b090a1 100644 --- a/BlazorApp/BlazorApp.csproj +++ b/BlazorApp/BlazorApp.csproj @@ -21,6 +21,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/BlazorApp/Components/Pages/Weather.razor b/BlazorApp/Components/Pages/Weather.razor index 94c2a88..f210f7b 100644 --- a/BlazorApp/Components/Pages/Weather.razor +++ b/BlazorApp/Components/Pages/Weather.razor @@ -44,7 +44,6 @@ else protected override async Task OnInitializedAsync() { - // Simulate asynchronous loading to demonstrate streaming rendering await Task.Delay(500); forecasts = await ForecastService.GetForecastAsync(DateOnly.FromDateTime(DateTime.Now)); } diff --git a/BlazorApp/Middlewares/ErrorHandlingMiddleware.cs b/BlazorApp/Middlewares/ErrorHandlingMiddleware.cs new file mode 100644 index 0000000..f1f783f --- /dev/null +++ b/BlazorApp/Middlewares/ErrorHandlingMiddleware.cs @@ -0,0 +1,71 @@ +using System.Net; +using BlazorApp.Models.Exceptions; +using Newtonsoft.Json; + +namespace BlazorApp.Middlewares +{ + public class ErrorHandlingMiddleware + { + private readonly RequestDelegate next; + private readonly ILogger _logger; + + public ErrorHandlingMiddleware( + RequestDelegate next, + ILogger logger) + { + this.next = next; + this._logger = logger; + } + + public async Task Invoke(HttpContext context) + { + try + { + await next(context); + } + catch (System.Exception ex) + { + await this.HandleAsync(context, ex); + } + } + + private async Task HandleAsync(HttpContext context, System.Exception exception) + { + HandledException handled = this.HandleException(exception); + + this._logger.LogError(exception, $"returning code {handled.StatusCode} and payload {handled.Message}"); + + context.Response.ContentType = "application/json"; + context.Response.StatusCode = (int)handled.StatusCode; + await context.Response.WriteAsync(handled.Message); + } + + protected HandledException HandleException(System.Exception exception) + { + HttpStatusCode statusCode; + + if (exception is NotFoundException) + { + statusCode = HttpStatusCode.NotFound; + } + else if (exception is ValidationException) + { + statusCode = HttpStatusCode.BadRequest; + } + else if (exception is BusinessException) + { + statusCode = HttpStatusCode.InternalServerError; + } + else + { + statusCode = HttpStatusCode.InternalServerError; + } + + return new HandledException() + { + StatusCode = statusCode, + Message = JsonConvert.SerializeObject(new { error = exception.Message }) + }; + } + } +} diff --git a/BlazorApp/Models/Exceptions/BusinessException.cs b/BlazorApp/Models/Exceptions/BusinessException.cs new file mode 100644 index 0000000..7d2fd57 --- /dev/null +++ b/BlazorApp/Models/Exceptions/BusinessException.cs @@ -0,0 +1,9 @@ +namespace BlazorApp.Models.Exceptions +{ + public class BusinessException : System.Exception + { + public BusinessException() : base() { } + public BusinessException(String message) : base(message) { } + public BusinessException(String message, System.Exception innerException) : base(message, innerException) { } + } +} diff --git a/BlazorApp/Models/Exceptions/HandledException.cs b/BlazorApp/Models/Exceptions/HandledException.cs new file mode 100644 index 0000000..b0cba1c --- /dev/null +++ b/BlazorApp/Models/Exceptions/HandledException.cs @@ -0,0 +1,10 @@ +using System.Net; + +namespace BlazorApp.Models.Exceptions +{ + public class HandledException + { + public HttpStatusCode StatusCode { get; set; } + public string Message { get; set; } + } +} diff --git a/BlazorApp/Models/Exceptions/NotFoundException.cs b/BlazorApp/Models/Exceptions/NotFoundException.cs new file mode 100644 index 0000000..7373b2f --- /dev/null +++ b/BlazorApp/Models/Exceptions/NotFoundException.cs @@ -0,0 +1,9 @@ +namespace BlazorApp.Models.Exceptions +{ + public class NotFoundException : System.Exception + { + public NotFoundException() : base() { } + public NotFoundException(String message) : base(message) { } + public NotFoundException(String message, System.Exception innerException) : base(message, innerException) { } + } +} diff --git a/BlazorApp/Models/Exceptions/ValidationException.cs b/BlazorApp/Models/Exceptions/ValidationException.cs new file mode 100644 index 0000000..ddbf7b9 --- /dev/null +++ b/BlazorApp/Models/Exceptions/ValidationException.cs @@ -0,0 +1,9 @@ +namespace BlazorApp.Models.Exceptions +{ + public class ValidationException : System.Exception + { + public ValidationException() : base() { } + public ValidationException(String message) : base(message) { } + public ValidationException(String message, System.Exception innerException) : base(message, innerException) { } + } +} diff --git a/BlazorApp/Program.cs b/BlazorApp/Program.cs index 8bf2a4a..250f93f 100644 --- a/BlazorApp/Program.cs +++ b/BlazorApp/Program.cs @@ -4,6 +4,7 @@ using BlazorApp.Components; using BlazorApp.Data.Context; using BlazorApp.Interfaces.Repositories; using BlazorApp.Interfaces.Services; +using BlazorApp.Middlewares; using BlazorApp.Models.Config; using BlazorApp.Repositories.Database; using BlazorApp.Repositories.InMemory; @@ -12,7 +13,6 @@ using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); -// Add services to the container. builder.Services.AddRazorComponents() .AddInteractiveServerComponents() .AddInteractiveWebAssemblyComponents(); @@ -41,9 +41,9 @@ builder.Services.AddClientServices(); var app = builder.Build(); +app.UseMiddleware(typeof(ErrorHandlingMiddleware)); app.ExecuteDbMigration(); -// Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.UseWebAssemblyDebugging(); @@ -51,7 +51,6 @@ if (app.Environment.IsDevelopment()) else { app.UseExceptionHandler("/Error", createScopeForErrors: true); - // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. app.UseHsts(); } diff --git a/BlazorApp/Repositories/Database/CustomerDatabaseRepository.cs b/BlazorApp/Repositories/Database/CustomerDatabaseRepository.cs index 00a2a22..ceb9e41 100644 --- a/BlazorApp/Repositories/Database/CustomerDatabaseRepository.cs +++ b/BlazorApp/Repositories/Database/CustomerDatabaseRepository.cs @@ -1,9 +1,9 @@ using BlazorApp.Data; using BlazorApp.Data.Context; using BlazorApp.Interfaces.Repositories; +using BlazorApp.Models.Exceptions; using BlazorApp.Shared.Queries; using Microsoft.EntityFrameworkCore; -using System.ComponentModel.DataAnnotations; namespace BlazorApp.Repositories.Database { @@ -27,7 +27,7 @@ namespace BlazorApp.Repositories.Database Customer data = await this._dbContext.Customers.FindAsync(id); if (data is null) { - throw new KeyNotFoundException($"Customer with id: {id} not found"); + throw new NotFoundException($"Customer with id: {id} not found"); } return data; } diff --git a/BlazorApp/Services/BaseService.cs b/BlazorApp/Services/BaseService.cs new file mode 100644 index 0000000..beba1a4 --- /dev/null +++ b/BlazorApp/Services/BaseService.cs @@ -0,0 +1,11 @@ +namespace BlazorApp.Services +{ + public class BaseService + { + protected readonly ILogger _logger; + public BaseService(ILogger logger) + { + this._logger = logger; + } + } +} diff --git a/BlazorApp/Services/CustomerService.cs b/BlazorApp/Services/CustomerService.cs index 3e2a650..ca9b8da 100644 --- a/BlazorApp/Services/CustomerService.cs +++ b/BlazorApp/Services/CustomerService.cs @@ -1,32 +1,38 @@ using BlazorApp.Interfaces.Repositories; using BlazorApp.Interfaces.Services; +using BlazorApp.Models.Exceptions; using BlazorApp.Shared.Models; using BlazorApp.Shared.Models.Pagination; using BlazorApp.Shared.Queries; namespace BlazorApp.Services { - public class CustomerService : ICustomerService + public class CustomerService : BaseService, ICustomerService { private readonly ICustomerRepository _customerRepository; - public CustomerService(ICustomerRepository customerRepository) + public CustomerService(ICustomerRepository customerRepository, ILogger logger) : base(logger) { this._customerRepository = customerRepository; } public async Task Delete(string id) { + this._logger.LogInformation($"Gonna delete customer with id: {id}"); await this._customerRepository.Delete(id); + this._logger.LogInformation($"Deleted customer with id: {id}"); } public async Task Get(string id) { + this._logger.LogInformation($"Gonna get customer with id: {id}"); Data.Customer data = await this._customerRepository.Get(id); + this._logger.LogInformation($"Got customer with id: {id}"); return data.Convert(); } public async Task> Query(CustomerQuery query) { + this._logger.LogInformation($"Querying customers"); IEnumerable datas = await this._customerRepository.Query(query); return new PaginatedResult() { @@ -37,12 +43,16 @@ namespace BlazorApp.Services public async Task Save(Customer customer) { + this._logger.LogInformation($"Gonna save new customer"); await this._customerRepository.Save(customer); + this._logger.LogInformation($"New customer saved successfully"); } public async Task Update(Customer customer) { + this._logger.LogInformation($"Gonna update customer with id: {customer.Id}"); await this._customerRepository.Update(customer); + this._logger.LogInformation($"Customer with id: {customer.Id} updated successfully"); } public async Task Count() => await this._customerRepository.Count(); diff --git a/BlazorApp/appsettings.Development.json b/BlazorApp/appsettings.Development.json index e590b7d..e420810 100644 --- a/BlazorApp/appsettings.Development.json +++ b/BlazorApp/appsettings.Development.json @@ -14,6 +14,6 @@ "CustomerCacheKey": "customer", "CustomerCacheSeconds": 5 }, - "MockCustomers": true, + "MockCustomers": false, "MockCustomersCount": 238 }