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
}