diff --git a/Controllers/TodoItemsController.cs b/Controllers/TodoItemsController.cs deleted file mode 100644 index 0ef138e7..00000000 --- a/Controllers/TodoItemsController.cs +++ /dev/null @@ -1,116 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using TodoApi.Models; - -namespace TodoApi.Controllers -{ - [Route("api/[controller]")] - [ApiController] - public class TodoItemsController : ControllerBase - { - private readonly TodoContext _context; - - public TodoItemsController(TodoContext context) - { - _context = context; - } - - [HttpGet] - public async Task>> GetTodoItems() - { - return await _context.TodoItems - .Select(x => ItemToDTO(x)) - .ToListAsync(); - } - - [HttpGet("{id}")] - public async Task> GetTodoItem(long id) - { - var todoItem = await _context.TodoItems.FindAsync(id); - - if (todoItem == null) - { - return NotFound(); - } - - return ItemToDTO(todoItem); - } - - [HttpPut("{id}")] - public async Task UpdateTodoItem(long id, TodoItemDTO todoItemDTO) - { - if (id != todoItemDTO.Id) - { - return BadRequest(); - } - - var todoItem = await _context.TodoItems.FindAsync(id); - if (todoItem == null) - { - return NotFound(); - } - - todoItem.Name = todoItemDTO.Name; - todoItem.IsComplete = todoItemDTO.IsComplete; - - try - { - await _context.SaveChangesAsync(); - } - catch (DbUpdateConcurrencyException) when (!TodoItemExists(id)) - { - return NotFound(); - } - - return NoContent(); - } - - [HttpPost] - public async Task> CreateTodoItem(TodoItemDTO todoItemDTO) - { - var todoItem = new TodoItem - { - IsComplete = todoItemDTO.IsComplete, - Name = todoItemDTO.Name - }; - - _context.TodoItems.Add(todoItem); - await _context.SaveChangesAsync(); - - return CreatedAtAction( - nameof(GetTodoItem), - new { id = todoItem.Id }, - ItemToDTO(todoItem)); - } - - [HttpDelete("{id}")] - public async Task DeleteTodoItem(long id) - { - var todoItem = await _context.TodoItems.FindAsync(id); - - if (todoItem == null) - { - return NotFound(); - } - - _context.TodoItems.Remove(todoItem); - await _context.SaveChangesAsync(); - - return NoContent(); - } - - private bool TodoItemExists(long id) => - _context.TodoItems.Any(e => e.Id == id); - - private static TodoItemDTO ItemToDTO(TodoItem todoItem) => - new TodoItemDTO - { - Id = todoItem.Id, - Name = todoItem.Name, - IsComplete = todoItem.IsComplete - }; - } -} diff --git a/Models/TodoContext.cs b/Models/TodoContext.cs deleted file mode 100644 index 6e59e363..00000000 --- a/Models/TodoContext.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace TodoApi.Models -{ - public class TodoContext : DbContext - { - public TodoContext(DbContextOptions options) - : base(options) - { - } - - public DbSet TodoItems { get; set; } - } -} \ No newline at end of file diff --git a/Models/TodoItem.cs b/Models/TodoItem.cs deleted file mode 100644 index 1f6e5465..00000000 --- a/Models/TodoItem.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace TodoApi.Models -{ - #region snippet - public class TodoItem - { - public long Id { get; set; } - public string Name { get; set; } - public bool IsComplete { get; set; } - public string Secret { get; set; } - } - #endregion -} \ No newline at end of file diff --git a/Models/TodoItemDTO.cs b/Models/TodoItemDTO.cs deleted file mode 100644 index e66a500a..00000000 --- a/Models/TodoItemDTO.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace TodoApi.Models -{ - #region snippet - public class TodoItemDTO - { - public long Id { get; set; } - public string Name { get; set; } - public bool IsComplete { get; set; } - } - #endregion -} diff --git a/Program.cs b/Program.cs deleted file mode 100644 index b27ac16a..00000000 --- a/Program.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace TodoApi -{ - public class Program - { - public static void Main(string[] args) - { - CreateHostBuilder(args).Build().Run(); - } - - public static IHostBuilder CreateHostBuilder(string[] args) => - Host.CreateDefaultBuilder(args) - .ConfigureWebHostDefaults(webBuilder => - { - webBuilder.UseStartup(); - }); - } -} diff --git a/README.md b/README.md index 466e41fd..f6d6006a 100644 --- a/README.md +++ b/README.md @@ -1 +1,17 @@ -# TodoService \ No newline at end of file +## Todo application + +**Requirements and additional updates** +- BLL and DAL layers are added, main TodoApiDTO is renamed to Todo. +- Unit tests are written for Todo: controllers and Todo.BLL: services +- Added swagger and implemented MS SQL, appsettings.json are updated +- Implemented Automapper and repository pattern +- Added exceptions model for API error handling +- Updated TodoItemDTO model, added separate DTO models for create and update operations + +**Layers/folder structure and unit tests** + +![folders_tests](https://github.com/ebushuev/VelvetechTestTask/assets/84620072/c5aa40d2-5d64-4527-b1d7-fbed16207189) + +**Live application on Swagger** + +![swagger](https://github.com/ebushuev/VelvetechTestTask/assets/84620072/99a9f0e2-2cd0-4328-9af7-2d53f0269aff) diff --git a/Startup.cs b/Startup.cs deleted file mode 100644 index bbfbc83d..00000000 --- a/Startup.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.HttpsPolicy; -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using TodoApi.Models; - -namespace TodoApi -{ - public class Startup - { - public Startup(IConfiguration configuration) - { - Configuration = configuration; - } - - public IConfiguration Configuration { get; } - - // This method gets called by the runtime. Use this method to add services to the container. - public void ConfigureServices(IServiceCollection services) - { - services.AddDbContext(opt => - opt.UseInMemoryDatabase("TodoList")); - services.AddControllers(); - } - - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - public void Configure(IApplicationBuilder app, IWebHostEnvironment env) - { - if (env.IsDevelopment()) - { - app.UseDeveloperExceptionPage(); - } - - app.UseHttpsRedirection(); - - app.UseRouting(); - - app.UseAuthorization(); - - app.UseEndpoints(endpoints => - { - endpoints.MapControllers(); - }); - } - } -} diff --git a/Todo.BLL.Tests/Todo.BLL.Tests.csproj b/Todo.BLL.Tests/Todo.BLL.Tests.csproj new file mode 100644 index 00000000..403a614d --- /dev/null +++ b/Todo.BLL.Tests/Todo.BLL.Tests.csproj @@ -0,0 +1,30 @@ + + + + net6.0 + enable + enable + + false + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/Todo.BLL.Tests/TodoItemServiceTests.cs b/Todo.BLL.Tests/TodoItemServiceTests.cs new file mode 100644 index 00000000..80ee5f94 --- /dev/null +++ b/Todo.BLL.Tests/TodoItemServiceTests.cs @@ -0,0 +1,103 @@ +using Moq; +using Todo.BLL.Interfaces; +using Todo.BLL.Services; +using Todo.DAL; +using Todo.DAL.Entities; +using Xunit; + +namespace Todo.BLL.Tests; + +public class TodoItemServiceTests +{ + private readonly Mock> repositoryMock; + private readonly ITodoItemService todoItemService; + + public TodoItemServiceTests() + { + repositoryMock = new Mock>(); + todoItemService = new TodoItemService(repositoryMock.Object); + } + + [Fact] + public async Task GetTodoItemsAsync_ShouldReturnAllTodoItems() + { + // Arrange + repositoryMock.Setup(repo => repo.GetAll(false)).ReturnsAsync(GetTestTodoItems()); + + // Act + var todoItems = await todoItemService.GetTodoItemsAsync(false); + + // Assert + repositoryMock.Verify(t => t.GetAll(false)); + Assert.NotNull(todoItems); + } + + [Fact] + public async Task GetTodoItemAsync_ShouldReturnTodoItemById() + { + // Arrange + var todoItems = GetTestTodoItems(); + var id = todoItems[0].Id; + + repositoryMock.Setup(repo => repo.GetByCondition(l => l.Id == id, false)).ReturnsAsync(todoItems[0]); + + // Act + var result = await todoItemService.GetTodoItemAsync(id, false); + + // Assert + Assert.Equal(todoItems[0], result); + } + + [Fact] + public async Task CreateToDoItemAsync_ShouldAddNewTodoItem() + { + // Arrange + var todoItem = GetTestTodoItems()[0]; + + // Act + await todoItemService.CreateToDoItemAsync(todoItem); + + // Assert + repositoryMock.Verify(t => t.Create(It.IsAny())); + repositoryMock.Verify(t => t.SaveChanges()); + } + + [Fact] + public async Task UpdateTodoItemAsync_ShouldUpdateTodoItem_CallOnce() + { + // Arrange + var todoItem = GetTestTodoItems()[0]; + + // Act + await todoItemService.UpdateTodoItemAsync(todoItem); + + // Assert + repositoryMock.Verify(t => t.Update(todoItem), Times.Once); + repositoryMock.Verify(t => t.SaveChanges()); + } + + [Fact] + public async Task DeleteTodoItemAsync_ShouldDeleteTodoItem_CallOnce() + { + // Arrange + var todoItem = GetTestTodoItems()[0]; + + // Act + await todoItemService.DeleteTodoItemAsync(todoItem); + + // Assert + repositoryMock.Verify(t => t.Delete(todoItem), Times.Once); + repositoryMock.Verify(t => t.SaveChanges()); + } + + private List GetTestTodoItems() + { + return new List + { + new TodoItem { Id = Guid.NewGuid(), Name = "Buy groceries", IsComplete = false }, + new TodoItem { Id = Guid.NewGuid(), Name = "Do laundry", IsComplete = true }, + new TodoItem { Id = Guid.NewGuid(), Name = "Read a book", IsComplete = false } + }; + } + +} diff --git a/Todo.BLL/Interfaces/ITodoItemService.cs b/Todo.BLL/Interfaces/ITodoItemService.cs new file mode 100644 index 00000000..4570b261 --- /dev/null +++ b/Todo.BLL/Interfaces/ITodoItemService.cs @@ -0,0 +1,16 @@ +using Todo.DAL.Entities; + +namespace Todo.BLL.Interfaces; + +public interface ITodoItemService +{ + Task> GetTodoItemsAsync(bool trackChanges); + + Task GetTodoItemAsync(Guid todoItemId, bool trackChanges); + + Task CreateToDoItemAsync(TodoItem todoItem); + + Task UpdateTodoItemAsync(TodoItem todoItem); + + Task DeleteTodoItemAsync(TodoItem todoItem); +} diff --git a/Todo.BLL/Services/TodoItemService.cs b/Todo.BLL/Services/TodoItemService.cs new file mode 100644 index 00000000..ab0d7248 --- /dev/null +++ b/Todo.BLL/Services/TodoItemService.cs @@ -0,0 +1,43 @@ +using Todo.DAL.Entities; +using Todo.DAL; +using Todo.BLL.Interfaces; + +namespace Todo.BLL.Services; + +public class TodoItemService : ITodoItemService +{ + private readonly IRepository _repository; + + public TodoItemService(IRepository repository) => _repository = repository; + + public async Task> GetTodoItemsAsync(bool trackChanges) + { + return await _repository.GetAll(trackChanges); + } + + public async Task GetTodoItemAsync(Guid todoItemId, bool trackChanges) + { + return await _repository.GetByCondition(l => l.Id == todoItemId, trackChanges); + } + + public async Task CreateToDoItemAsync(TodoItem todoItem) + { + _repository.Create(todoItem); + + await _repository.SaveChanges(); + } + + public async Task DeleteTodoItemAsync(TodoItem todoItem) + { + _repository.Delete(todoItem); + + await _repository.SaveChanges(); + } + + public async Task UpdateTodoItemAsync(TodoItem todoItem) + { + _repository.Update(todoItem); + + await _repository.SaveChanges(); + } +} diff --git a/Todo.BLL/Todo.BLL.csproj b/Todo.BLL/Todo.BLL.csproj new file mode 100644 index 00000000..49c4d696 --- /dev/null +++ b/Todo.BLL/Todo.BLL.csproj @@ -0,0 +1,13 @@ + + + + net6.0 + enable + enable + + + + + + + diff --git a/Todo.DAL/DbContexts/TodoDbContext.cs b/Todo.DAL/DbContexts/TodoDbContext.cs new file mode 100644 index 00000000..d3ed20ad --- /dev/null +++ b/Todo.DAL/DbContexts/TodoDbContext.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; +using Todo.DAL.Entities; + +namespace Todo.DAL.DbContexts; + +public class TodoDbContext : DbContext +{ + public TodoDbContext(DbContextOptions options) + : base(options) + { + } + + public DbSet TodoItems { get; set; } +} diff --git a/Todo.DAL/Entities/TodoItem.cs b/Todo.DAL/Entities/TodoItem.cs new file mode 100644 index 00000000..9eb3bd74 --- /dev/null +++ b/Todo.DAL/Entities/TodoItem.cs @@ -0,0 +1,12 @@ +namespace Todo.DAL.Entities; + +public class TodoItem +{ + public Guid Id { get; set; } + + public string Name { get; set; } + + public bool IsComplete { get; set; } + + public string? Secret { get; set; } +} diff --git a/Todo.DAL/IRepository.cs b/Todo.DAL/IRepository.cs new file mode 100644 index 00000000..15f93d23 --- /dev/null +++ b/Todo.DAL/IRepository.cs @@ -0,0 +1,18 @@ +using System.Linq.Expressions; + +namespace Todo.DAL; + +public interface IRepository where T : class +{ + Task> GetAll(bool trackChanges); + + Task GetByCondition(Expression> expression, bool trackChanges); + + Task SaveChanges(); + + void Create(T entity); + + void Update(T entity); + + void Delete(T entity); +} diff --git a/Todo.DAL/Migrations/20230805025418_Initial.Designer.cs b/Todo.DAL/Migrations/20230805025418_Initial.Designer.cs new file mode 100644 index 00000000..5261c3a6 --- /dev/null +++ b/Todo.DAL/Migrations/20230805025418_Initial.Designer.cs @@ -0,0 +1,51 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Todo.DAL.DbContexts; + +#nullable disable + +namespace Todo.DAL.Migrations +{ + [DbContext(typeof(TodoDbContext))] + [Migration("20230805025418_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Todo.DAL.Entities.TodoItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("IsComplete") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Secret") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("TodoItems"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Todo.DAL/Migrations/20230805025418_Initial.cs b/Todo.DAL/Migrations/20230805025418_Initial.cs new file mode 100644 index 00000000..8a7d6f3f --- /dev/null +++ b/Todo.DAL/Migrations/20230805025418_Initial.cs @@ -0,0 +1,36 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Todo.DAL.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "TodoItems", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(max)", nullable: false), + IsComplete = table.Column(type: "bit", nullable: false), + Secret = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TodoItems", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TodoItems"); + } + } +} diff --git a/Todo.DAL/Migrations/TodoDbContextModelSnapshot.cs b/Todo.DAL/Migrations/TodoDbContextModelSnapshot.cs new file mode 100644 index 00000000..e609451f --- /dev/null +++ b/Todo.DAL/Migrations/TodoDbContextModelSnapshot.cs @@ -0,0 +1,48 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Todo.DAL.DbContexts; + +#nullable disable + +namespace Todo.DAL.Migrations +{ + [DbContext(typeof(TodoDbContext))] + partial class TodoDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Todo.DAL.Entities.TodoItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("IsComplete") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Secret") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("TodoItems"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Todo.DAL/Repository.cs b/Todo.DAL/Repository.cs new file mode 100644 index 00000000..e7670248 --- /dev/null +++ b/Todo.DAL/Repository.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore; +using System.Linq.Expressions; +using Todo.DAL.DbContexts; + +namespace Todo.DAL; + +public class Repository : IRepository where T : class +{ + private readonly TodoDbContext _dbContext; + + public Repository(TodoDbContext dbContext) + => _dbContext = dbContext; + + public async Task> GetAll(bool trackChanges) + { + IQueryable query = !trackChanges ? _dbContext.Set().AsNoTracking() : _dbContext.Set(); + + return await query.ToListAsync(); + } + + public async Task GetByCondition(Expression> expression, bool trackChanges) + { + IQueryable query = !trackChanges ? _dbContext.Set().Where(expression).AsNoTracking() + : _dbContext.Set().Where(expression); + + return await query.FirstOrDefaultAsync(); + } + + public void Create(T entity) => _dbContext.Set().Add(entity); + + public void Update(T entity) => _dbContext.Set().Update(entity); + + public void Delete(T entity) => _dbContext.Set().Remove(entity); + + public async Task SaveChanges() => await _dbContext.SaveChangesAsync(); +} diff --git a/Todo.DAL/Todo.DAL.csproj b/Todo.DAL/Todo.DAL.csproj new file mode 100644 index 00000000..71552da6 --- /dev/null +++ b/Todo.DAL/Todo.DAL.csproj @@ -0,0 +1,18 @@ + + + + net6.0 + enable + enable + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/Todo.Tests/Todo.Tests.csproj b/Todo.Tests/Todo.Tests.csproj new file mode 100644 index 00000000..1ecaeb49 --- /dev/null +++ b/Todo.Tests/Todo.Tests.csproj @@ -0,0 +1,31 @@ + + + + net6.0 + enable + enable + + false + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/Todo.Tests/TodoItemsControllerTests.cs b/Todo.Tests/TodoItemsControllerTests.cs new file mode 100644 index 00000000..4cf85abb --- /dev/null +++ b/Todo.Tests/TodoItemsControllerTests.cs @@ -0,0 +1,143 @@ +using AutoMapper; +using Microsoft.AspNetCore.Mvc; +using Moq; +using Todo.BLL.Interfaces; +using Todo.Controllers; +using Todo.DAL.Entities; +using Todo.Dtos; +using Todo.Exceptions.TodoItem; +using Xunit; + +namespace Todo.Tests; + +public class TodoItemsControllerTests +{ + private static readonly Guid todoItemId = Guid.NewGuid(); + private readonly Mock mockMapper = new Mock(); + private readonly Mock mockTodoItemService = new Mock(); + + private readonly IReadOnlyList todoItems = + new List + { + new TodoItem { Id = todoItemId, Name = "Buy groceries", IsComplete = false }, + new TodoItem { Id = Guid.NewGuid(), Name = "Do homework", IsComplete = true }, + new TodoItem { Id = Guid.NewGuid(), Name = "Read a book", IsComplete = false } + }; + + private TodoItemsController todoItemsController; + + public TodoItemsControllerTests() + { + mockMapper.Setup(m => m.Map>(It.IsAny>())) + .Returns((List items) => items.Select(item => new TodoItemDto(item.Id, item.Name, item.IsComplete))); + + mockMapper.Setup(m => m.Map(It.IsAny())) + .Returns((TodoItem item) => new TodoItemDto(item.Id, item.Name, item.IsComplete)); + + mockTodoItemService.Setup(service => service.GetTodoItemsAsync(false)).ReturnsAsync(todoItems); + + mockTodoItemService.Setup(t => t.GetTodoItemAsync(todoItemId, false)).ReturnsAsync(todoItems[0]); + + todoItemsController = new TodoItemsController(mockTodoItemService.Object, mockMapper.Object); + } + + [Fact] + public async Task GetTodoItems_ShouldReturnOkAndCallOnce_WhenTodoItemsExist() + { + // Act + var result = await todoItemsController.GetTodoItems(); + + // Assert + Assert.IsType>>(result); + mockTodoItemService.Verify(mock => mock.GetTodoItemsAsync(false), Times.Once()); + } + + [Fact] + public async Task GetTodoItem_ShouldReturnOkAndCallOnce_WhenTodoItemExists() + { + // Act + var result = await todoItemsController.GetTodoItem(todoItemId); + + // Assert + Assert.IsType(result.Result); + mockTodoItemService.Verify(mock => mock.GetTodoItemAsync(todoItemId, false), Times.Once()); + } + + [Fact] + public async Task GetTodoItem_ShouldThrowException_WhenTodoItemDoesNotExist() + { + // Arrange + var mockTodoItemId = Guid.NewGuid(); + mockTodoItemService.Setup(t => t.GetTodoItemAsync(mockTodoItemId, false)).ReturnsAsync((TodoItem)null); + + // Act and Assert + await Assert.ThrowsAsync(() => todoItemsController.GetTodoItem(mockTodoItemId)); + } + + [Fact] + public async Task CreateTodoItem_ShouldReturnCreatedAtRouteResultWithCreatedTodoItem() + { + // Arrange + var mockCreationDto = new TodoItemForCreationDto { Name = "Write unit tests", IsComplete = false }; + var mockEntity = new TodoItem { Id = Guid.NewGuid(), Name = "Write unit tests", IsComplete = false }; + + mockMapper.Setup(m => m.Map(mockCreationDto)).Returns(mockEntity); + mockTodoItemService.Setup(s => s.CreateToDoItemAsync(mockEntity)).Returns(Task.CompletedTask); + + // Act + var result = await todoItemsController.CreateTodoItem(mockCreationDto); + + // Assert + Assert.IsType(result); + } + + [Fact] + public async Task UpdateTodoItem_ShouldReturnNoContentResult() + { + // Arrange + var updateItemDto = new TodoItemForUpdateDto { Name = "Do workout", IsComplete = true }; + var updateEntity = new TodoItem { Id = todoItemId, Name = "Do workout", IsComplete = true }; + + mockMapper.Setup(mapper => mapper.Map(updateItemDto, todoItems[0])).Returns(updateEntity); + mockTodoItemService.Setup(service => service.UpdateTodoItemAsync(updateEntity)).Returns(Task.CompletedTask); + + // Act + var result = await todoItemsController.UpdateTodoItem(todoItemId, updateItemDto); + + // Assert + Assert.IsType(result); + } + + [Fact] + public async Task UpdateTodoItem_ShouldThrowException_WhenTodoItemDoesNotExist() + { + // Arrange + var mockTodoItemId = Guid.NewGuid(); + var updateItemDto = new TodoItemForUpdateDto { Name = "Do workout", IsComplete = true }; + mockTodoItemService.Setup(t => t.GetTodoItemAsync(mockTodoItemId, false)).ReturnsAsync((TodoItem)null); + + // Act and Assert + await Assert.ThrowsAsync(() => todoItemsController.UpdateTodoItem(mockTodoItemId, updateItemDto)); + } + + [Fact] + public async Task DeleteTodoItem_ShouldReturnNoContentResult() + { + // Act + var result = await todoItemsController.DeleteTodoItem(todoItemId); + + // Assert + Assert.IsType(result); + } + + [Fact] + public async Task DeleteTodoItem_ShouldThrowException_WhenTodoItemDoesNotExist() + { + // Arrange + var mockTodoItemId = Guid.NewGuid(); + mockTodoItemService.Setup(t => t.GetTodoItemAsync(mockTodoItemId, false)).ReturnsAsync((TodoItem)null); + + // Act and Assert + await Assert.ThrowsAsync(() => todoItemsController.DeleteTodoItem(mockTodoItemId)); + } +} diff --git a/Todo.sln b/Todo.sln new file mode 100644 index 00000000..2b73233d --- /dev/null +++ b/Todo.sln @@ -0,0 +1,60 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.6.33723.286 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{F1C2A150-F045-49E3-B04A-28E49DAFCA74}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{AE374853-4D57-49A8-A80B-8D905FB916A1}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Todo", "Todo\Todo.csproj", "{9FD16944-03B3-4470-848D-F9295C30BB5A}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Todo.BLL", "Todo.BLL\Todo.BLL.csproj", "{E05D0E88-CC60-493C-B559-E8B8403AF0DC}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Todo.DAL", "Todo.DAL\Todo.DAL.csproj", "{919C9C3A-C976-459F-939A-FA87759D4FE6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Todo.BLL.Tests", "Todo.BLL.Tests\Todo.BLL.Tests.csproj", "{8794766B-E3C6-4C11-8A6D-B10074B54FE5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Todo.Tests", "Todo.Tests\Todo.Tests.csproj", "{8C68602A-D794-4726-8353-D3AD9CF24C30}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {9FD16944-03B3-4470-848D-F9295C30BB5A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9FD16944-03B3-4470-848D-F9295C30BB5A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9FD16944-03B3-4470-848D-F9295C30BB5A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9FD16944-03B3-4470-848D-F9295C30BB5A}.Release|Any CPU.Build.0 = Release|Any CPU + {E05D0E88-CC60-493C-B559-E8B8403AF0DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E05D0E88-CC60-493C-B559-E8B8403AF0DC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E05D0E88-CC60-493C-B559-E8B8403AF0DC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E05D0E88-CC60-493C-B559-E8B8403AF0DC}.Release|Any CPU.Build.0 = Release|Any CPU + {919C9C3A-C976-459F-939A-FA87759D4FE6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {919C9C3A-C976-459F-939A-FA87759D4FE6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {919C9C3A-C976-459F-939A-FA87759D4FE6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {919C9C3A-C976-459F-939A-FA87759D4FE6}.Release|Any CPU.Build.0 = Release|Any CPU + {8794766B-E3C6-4C11-8A6D-B10074B54FE5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8794766B-E3C6-4C11-8A6D-B10074B54FE5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8794766B-E3C6-4C11-8A6D-B10074B54FE5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8794766B-E3C6-4C11-8A6D-B10074B54FE5}.Release|Any CPU.Build.0 = Release|Any CPU + {8C68602A-D794-4726-8353-D3AD9CF24C30}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8C68602A-D794-4726-8353-D3AD9CF24C30}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8C68602A-D794-4726-8353-D3AD9CF24C30}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8C68602A-D794-4726-8353-D3AD9CF24C30}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {9FD16944-03B3-4470-848D-F9295C30BB5A} = {F1C2A150-F045-49E3-B04A-28E49DAFCA74} + {E05D0E88-CC60-493C-B559-E8B8403AF0DC} = {F1C2A150-F045-49E3-B04A-28E49DAFCA74} + {919C9C3A-C976-459F-939A-FA87759D4FE6} = {F1C2A150-F045-49E3-B04A-28E49DAFCA74} + {8794766B-E3C6-4C11-8A6D-B10074B54FE5} = {AE374853-4D57-49A8-A80B-8D905FB916A1} + {8C68602A-D794-4726-8353-D3AD9CF24C30} = {AE374853-4D57-49A8-A80B-8D905FB916A1} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {60984BC9-01D1-4B40-9733-E459B1231F96} + EndGlobalSection +EndGlobal diff --git a/Todo/Controllers/TodoItemsController.cs b/Todo/Controllers/TodoItemsController.cs new file mode 100644 index 00000000..b07ef988 --- /dev/null +++ b/Todo/Controllers/TodoItemsController.cs @@ -0,0 +1,77 @@ +using AutoMapper; +using Microsoft.AspNetCore.Mvc; +using Todo.BLL.Interfaces; +using Todo.DAL.Entities; +using Todo.Dtos; +using Todo.Exceptions.TodoItem; + +namespace Todo.Controllers; + +[Route("api/[controller]")] +[ApiController] +public class TodoItemsController : ControllerBase +{ + private readonly ITodoItemService _service; + private readonly IMapper _mapper; + + public TodoItemsController(ITodoItemService service, IMapper mapper) + { + _service = service; + _mapper = mapper; + } + + [HttpGet("getAll")] + public async Task>> GetTodoItems() + { + var todoItems = await _service.GetTodoItemsAsync(trackChanges: false); + + return Ok(_mapper.Map>(todoItems)); + } + + [HttpGet("{todoItemId:guid}", Name = "TodoItemById")] + public async Task> GetTodoItem(Guid todoItemId) + { + var todoItem = await GetTodoItemAndCheckIfItExists(todoItemId); + + return Ok(_mapper.Map(todoItem)); + } + + [HttpPost("create")] + public async Task CreateTodoItem([FromBody] TodoItemForCreationDto itemDto) + { + var todoItemEntity = _mapper.Map(itemDto); + await _service.CreateToDoItemAsync(todoItemEntity); + + var createdTodoItem = _mapper.Map(todoItemEntity); + return CreatedAtRoute("TodoItemById", new { todoItemId = createdTodoItem.Id }, createdTodoItem); + } + + [HttpPut("{todoItemId:guid}")] + public async Task UpdateTodoItem(Guid todoItemId, [FromBody] TodoItemForUpdateDto itemDto) + { + var todoItem = await GetTodoItemAndCheckIfItExists(todoItemId); + _mapper.Map(itemDto, todoItem); + + await _service.UpdateTodoItemAsync(todoItem); + + return NoContent(); + } + + [HttpDelete("{todoItemId:guid}")] + public async Task DeleteTodoItem(Guid todoItemId) + { + var todoItem = await GetTodoItemAndCheckIfItExists(todoItemId); + await _service.DeleteTodoItemAsync(todoItem); + + return NoContent(); + } + + private async Task GetTodoItemAndCheckIfItExists(Guid id) + { + var todoItem = await _service.GetTodoItemAsync(id, trackChanges: false); + if (todoItem is null) + throw new TodoItemNotFoundException(id); + + return todoItem; + } +} diff --git a/Todo/Dtos/TodoItemDto.cs b/Todo/Dtos/TodoItemDto.cs new file mode 100644 index 00000000..c53fd3fe --- /dev/null +++ b/Todo/Dtos/TodoItemDto.cs @@ -0,0 +1,3 @@ +namespace Todo.Dtos; + +public record TodoItemDto(Guid Id, string Name, bool IsComplete); \ No newline at end of file diff --git a/Todo/Dtos/TodoItemForCreationDto.cs b/Todo/Dtos/TodoItemForCreationDto.cs new file mode 100644 index 00000000..a2de78b0 --- /dev/null +++ b/Todo/Dtos/TodoItemForCreationDto.cs @@ -0,0 +1,3 @@ +namespace Todo.Dtos; + +public record TodoItemForCreationDto : TodoItemForManipulationDto; diff --git a/Todo/Dtos/TodoItemForManipulationDto.cs b/Todo/Dtos/TodoItemForManipulationDto.cs new file mode 100644 index 00000000..583bf420 --- /dev/null +++ b/Todo/Dtos/TodoItemForManipulationDto.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace Todo.Dtos; + +public abstract record TodoItemForManipulationDto +{ + [Required(ErrorMessage = "Name is a required field.")] + public string Name { get; init; } + + [Required(ErrorMessage = "IsComplete is a required field.")] + public bool IsComplete { get; init; } +} diff --git a/Todo/Dtos/TodoItemForUpdateDto.cs b/Todo/Dtos/TodoItemForUpdateDto.cs new file mode 100644 index 00000000..37869ac8 --- /dev/null +++ b/Todo/Dtos/TodoItemForUpdateDto.cs @@ -0,0 +1,3 @@ +namespace Todo.Dtos; + +public record TodoItemForUpdateDto : TodoItemForManipulationDto; \ No newline at end of file diff --git a/Todo/Exceptions/ErrorDetails.cs b/Todo/Exceptions/ErrorDetails.cs new file mode 100644 index 00000000..b3b2f90c --- /dev/null +++ b/Todo/Exceptions/ErrorDetails.cs @@ -0,0 +1,11 @@ +using System.Text.Json; + +namespace Todo.Exceptions; + +public class ErrorDetails +{ + public int StatusCode { get; set; } + public string? Message { get; set; } + + public override string ToString() => JsonSerializer.Serialize(this); +} diff --git a/Todo/Exceptions/NotFoundException.cs b/Todo/Exceptions/NotFoundException.cs new file mode 100644 index 00000000..a36d0034 --- /dev/null +++ b/Todo/Exceptions/NotFoundException.cs @@ -0,0 +1,8 @@ +namespace Todo.Exceptions; + +public abstract class NotFoundException : Exception +{ + protected NotFoundException(string message) + : base(message) + { } +} diff --git a/Todo/Exceptions/TodoItem/TodoItemNotFoundException.cs b/Todo/Exceptions/TodoItem/TodoItemNotFoundException.cs new file mode 100644 index 00000000..5650e8cb --- /dev/null +++ b/Todo/Exceptions/TodoItem/TodoItemNotFoundException.cs @@ -0,0 +1,9 @@ +namespace Todo.Exceptions.TodoItem; + +public class TodoItemNotFoundException : NotFoundException +{ + public TodoItemNotFoundException(Guid todoItemId) + : base($"TodoItem with id: {todoItemId} doesn't exist in the database.") + { + } +} diff --git a/Todo/Extensions/ExceptionMiddlewareExtensions.cs b/Todo/Extensions/ExceptionMiddlewareExtensions.cs new file mode 100644 index 00000000..2216ea4e --- /dev/null +++ b/Todo/Extensions/ExceptionMiddlewareExtensions.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Diagnostics; +using Todo.Exceptions; + +namespace Todo.Extensions; + +public static class ExceptionMiddlewareExtensions +{ + public static void ConfigureExceptionHandler(this WebApplication app) + { + app.UseExceptionHandler(appError => + { + appError.Run(async context => + { + context.Response.ContentType = "application/json"; + + var contextFeature = context.Features.Get(); + if (contextFeature != null) + { + context.Response.StatusCode = contextFeature.Error switch + { + NotFoundException => StatusCodes.Status404NotFound, + _ => StatusCodes.Status500InternalServerError + }; + + await context.Response.WriteAsync(new ErrorDetails() + { + StatusCode = context.Response.StatusCode, + Message = contextFeature.Error.Message, + }.ToString()); + } + }); + }); + } +} diff --git a/Todo/Extensions/ServiceExtensions.cs b/Todo/Extensions/ServiceExtensions.cs new file mode 100644 index 00000000..568cefbb --- /dev/null +++ b/Todo/Extensions/ServiceExtensions.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.OpenApi.Models; +using Todo.BLL.Interfaces; +using Todo.BLL.Services; +using Todo.DAL; +using Todo.DAL.DbContexts; +using Todo.DAL.Entities; + +namespace Todo.Extensions; + +public static class ServiceExtensions +{ + public static void ConfigureRepositories(this IServiceCollection services) => + services.AddScoped, Repository>(); + + public static void ConfigureSqlContext(this IServiceCollection services, IConfiguration configuration) => + services.AddDbContext(opts => + opts.UseSqlServer(configuration.GetConnectionString("SqlConnection"))); + + public static void ConfigureServices(this IServiceCollection services) => + services.AddScoped(); + + public static void ConfigureSwagger(this IServiceCollection services) => + services.AddSwaggerGen(options => + { + options.SwaggerDoc("v1", new OpenApiInfo { Title = "Todo", Version = "v1" }); + }); +} diff --git a/Todo/MappingProfile.cs b/Todo/MappingProfile.cs new file mode 100644 index 00000000..4ef8a9b5 --- /dev/null +++ b/Todo/MappingProfile.cs @@ -0,0 +1,17 @@ +using AutoMapper; +using Todo.Dtos; +using Todo.DAL.Entities; + +namespace Todo; + +public class MappingProfile : Profile +{ + public MappingProfile() + { + CreateMap(); + + CreateMap(); + + CreateMap().ReverseMap(); + } +} diff --git a/Todo/Program.cs b/Todo/Program.cs new file mode 100644 index 00000000..33ec9b62 --- /dev/null +++ b/Todo/Program.cs @@ -0,0 +1,35 @@ +using Todo.Extensions; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.ConfigureRepositories(); +builder.Services.ConfigureServices(); + +builder.Services.ConfigureSqlContext(builder.Configuration); + +builder.Services.AddAutoMapper(typeof(Program)); + +builder.Services.ConfigureSwagger(); +builder.Services.AddControllers(); +var app = builder.Build(); + +app.ConfigureExceptionHandler(); + +if (app.Environment.IsProduction()) + app.UseHsts(); + +app.UseHttpsRedirection(); +app.UseStaticFiles(); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.UseSwagger(); +app.UseSwaggerUI(s => +{ + s.SwaggerEndpoint("/swagger/v1/swagger.json", "Todo v1"); +}); + +app.MapControllers(); + +app.Run(); \ No newline at end of file diff --git a/Properties/launchSettings.json b/Todo/Properties/launchSettings.json similarity index 58% rename from Properties/launchSettings.json rename to Todo/Properties/launchSettings.json index 6766196a..acda0447 100644 --- a/Properties/launchSettings.json +++ b/Todo/Properties/launchSettings.json @@ -1,27 +1,31 @@ -{ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", "iisSettings": { "windowsAuthentication": false, "anonymousAuthentication": true, "iisExpress": { - "applicationUrl": "http://localhost:56416/", - "sslPort": 44331 + "applicationUrl": "http://localhost:60930", + "sslPort": 44360 } }, "profiles": { - "IIS Express": { - "commandName": "IISExpress", + "Todo": { + "commandName": "Project", + "dotnetRunMessages": true, "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7111;http://localhost:5281", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } }, - "TodoApiDTO": { - "commandName": "Project", + "IIS Express": { + "commandName": "IISExpress", "launchBrowser": true, + "launchUrl": "swagger", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" - }, - "applicationUrl": "https://localhost:5001;http://localhost:5000" + } } } -} \ No newline at end of file +} diff --git a/Todo/Todo.csproj b/Todo/Todo.csproj new file mode 100644 index 00000000..f9eedc02 --- /dev/null +++ b/Todo/Todo.csproj @@ -0,0 +1,23 @@ + + + + net6.0 + enable + enable + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/Todo/appsettings.Development.json b/Todo/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/Todo/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Todo/appsettings.json b/Todo/appsettings.json new file mode 100644 index 00000000..518cf1f7 --- /dev/null +++ b/Todo/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "ConnectionStrings": { + "SqlConnection": "Server=DESKTOP-OTFC9HN;Database=todoDB;Trusted_Connection=True;MultipleActiveResultSets=true" + }, + "AllowedHosts": "*" +} diff --git a/TodoApiDTO.csproj b/TodoApiDTO.csproj deleted file mode 100644 index bba6f6af..00000000 --- a/TodoApiDTO.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - netcoreapp3.1 - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - diff --git a/TodoApiDTO.sln b/TodoApiDTO.sln deleted file mode 100644 index e49c182b..00000000 --- a/TodoApiDTO.sln +++ /dev/null @@ -1,25 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.30002.166 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TodoApiDTO", "TodoApiDTO.csproj", "{623124F9-F5BA-42DD-BC26-A1720774229C}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {623124F9-F5BA-42DD-BC26-A1720774229C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {623124F9-F5BA-42DD-BC26-A1720774229C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {623124F9-F5BA-42DD-BC26-A1720774229C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {623124F9-F5BA-42DD-BC26-A1720774229C}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {60984BC9-01D1-4B40-9733-E459B1231F96} - EndGlobalSection -EndGlobal diff --git a/appsettings.Development.json b/appsettings.Development.json deleted file mode 100644 index 8983e0fc..00000000 --- a/appsettings.Development.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft": "Warning", - "Microsoft.Hosting.Lifetime": "Information" - } - } -} diff --git a/appsettings.json b/appsettings.json deleted file mode 100644 index d9d9a9bf..00000000 --- a/appsettings.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft": "Warning", - "Microsoft.Hosting.Lifetime": "Information" - } - }, - "AllowedHosts": "*" -}