diff --git a/AdvancedTodoList.Core/Dtos/TodoListDtos.cs b/AdvancedTodoList.Core/Dtos/TodoListDtos.cs index a5f3adc..e30e653 100644 --- a/AdvancedTodoList.Core/Dtos/TodoListDtos.cs +++ b/AdvancedTodoList.Core/Dtos/TodoListDtos.cs @@ -1,7 +1,11 @@ namespace AdvancedTodoList.Core.Dtos; -public record TodoListItemView(int Id); +/// +/// DTO for creating/editing a to-do list. +/// +public record TodoListCreateDto(string Name, string Description); -public record TodoListCreateDto(string Name); - -public record TodoListGetByIdDto(string Id, string Name, TodoListItemView[] TodoItems); +/// +/// DTO for a full view of a to-do list. +/// +public record TodoListGetByIdDto(string Id, string Name, string Description); diff --git a/AdvancedTodoList.Core/Dtos/TodoListItemDtos.cs b/AdvancedTodoList.Core/Dtos/TodoListItemDtos.cs new file mode 100644 index 0000000..e44c370 --- /dev/null +++ b/AdvancedTodoList.Core/Dtos/TodoListItemDtos.cs @@ -0,0 +1,30 @@ +using AdvancedTodoList.Core.Models.TodoLists; + +namespace AdvancedTodoList.Core.Dtos; + +/// +/// DTO for creating/editing a to-do list item. +/// +public record TodoItemCreateDto(string Name, string Description, DateTime? DeadlineDate); + +/// +/// DTO for changing the state of a to-do list item. +/// +public record TodoItemUpdateStateDto(TodoItemState State); + +/// +/// DTO for a full view of a to-do list item. +/// +public record TodoItemGetByIdDto( + int Id, string TodoListId, string Name, + string Description, DateTime? DeadlineDate, + TodoItemState State + ); + +/// +/// DTO for a partial view of a to-do list item. +/// +public record TodoItemPreviewDto( + int Id, string TodoListId, string Name, + DateTime? DeadlineDate, TodoItemState State + ); \ No newline at end of file diff --git a/AdvancedTodoList.Core/Mapping/MappingGlobalSettings.cs b/AdvancedTodoList.Core/Mapping/MappingGlobalSettings.cs new file mode 100644 index 0000000..98e2378 --- /dev/null +++ b/AdvancedTodoList.Core/Mapping/MappingGlobalSettings.cs @@ -0,0 +1,19 @@ +using Mapster; + +namespace AdvancedTodoList.Core.Mapping; + +/// +/// Class that defines global mapping settings. +/// +public static class MappingGlobalSettings +{ + /// + /// Apply global mapping settings. + /// + public static void Apply() + { + // Convert null strings into empty strings and trim strings + TypeAdapterConfig.GlobalSettings.Default + .AddDestinationTransform((string? dest) => dest != null ? dest.Trim() : string.Empty); + } +} diff --git a/AdvancedTodoList.Core/Models/ApplicationUser.cs b/AdvancedTodoList.Core/Models/ApplicationUser.cs index 0cde393..81bfee3 100644 --- a/AdvancedTodoList.Core/Models/ApplicationUser.cs +++ b/AdvancedTodoList.Core/Models/ApplicationUser.cs @@ -2,6 +2,6 @@ namespace AdvancedTodoList.Core.Models; -public class ApplicationUser : IdentityUser +public class ApplicationUser : IdentityUser, IEntity { } diff --git a/AdvancedTodoList.Core/Models/IEntity.cs b/AdvancedTodoList.Core/Models/IEntity.cs new file mode 100644 index 0000000..7a31f73 --- /dev/null +++ b/AdvancedTodoList.Core/Models/IEntity.cs @@ -0,0 +1,10 @@ +namespace AdvancedTodoList.Core.Models; + +/// +/// An interface that represents an entity with an ID property. +/// +/// Type of the entity ID. +public interface IEntity where TId : IEquatable +{ + TId Id { get; } +} diff --git a/AdvancedTodoList.Core/Models/TodoLists/TodoItem.cs b/AdvancedTodoList.Core/Models/TodoLists/TodoItem.cs index fc95179..537c82f 100644 --- a/AdvancedTodoList.Core/Models/TodoLists/TodoItem.cs +++ b/AdvancedTodoList.Core/Models/TodoLists/TodoItem.cs @@ -6,27 +6,27 @@ namespace AdvancedTodoList.Core.Models.TodoLists; /// /// Represents a to-do list item entity. /// -public class TodoItem +public class TodoItem : IEntity { - /// - /// An unique identifier for the to-do list item. - /// - [Key] - public int Id { get; set; } - /// - /// Name (title) of the to-do item. - /// - [MaxLength(NameMaxLength)] - public string Name { get; set; } = null!; - /// - /// Description of the to-do item. - /// - [MaxLength(DescriptionMaxLength)] - public string Description { get; set; } = null!; - /// - /// Current state of the to-do item. - /// - public TodoItemState State { get; set; } + /// + /// An unique identifier for the to-do list item. + /// + [Key] + public int Id { get; set; } + /// + /// Name (title) of the to-do item. + /// + [MaxLength(NameMaxLength)] + public string Name { get; set; } = null!; + /// + /// Description of the to-do item. + /// + [MaxLength(DescriptionMaxLength)] + public string Description { get; set; } = null!; + /// + /// Current state of the to-do item. + /// + public TodoItemState State { get; set; } /// /// Deadline date for the todo item. Can be null. /// @@ -42,15 +42,15 @@ public class TodoItem /// Maximum allowed length of . /// public const int NameMaxLength = 100; - /// - /// Maximum allowed length of . - /// - public const int DescriptionMaxLength = 10_000; + /// + /// Maximum allowed length of . + /// + public const int DescriptionMaxLength = 10_000; - /// - /// To-do list associated with this to-do item. - /// - public TodoList TodoList { get; set; } = null!; + /// + /// To-do list associated with this to-do item. + /// + public TodoList TodoList { get; set; } = null!; } /// diff --git a/AdvancedTodoList.Core/Models/TodoLists/TodoList.cs b/AdvancedTodoList.Core/Models/TodoLists/TodoList.cs index 596625d..9aa22bd 100644 --- a/AdvancedTodoList.Core/Models/TodoLists/TodoList.cs +++ b/AdvancedTodoList.Core/Models/TodoLists/TodoList.cs @@ -6,20 +6,20 @@ namespace AdvancedTodoList.Core.Models.TodoLists; /// /// Represents a to-do list entity. /// -public class TodoList +public class TodoList : IEntity { - /// - /// An unique identifier for the to-do list. - /// - [Key] - [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public string Id { get; set; } = null!; + /// + /// An unique identifier for the to-do list. + /// + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public string Id { get; set; } = null!; - /// - /// Name (title) of the to-do list. - /// - [MaxLength(NameMaxLength)] - public string Name { get; set; } = null!; + /// + /// Name (title) of the to-do list. + /// + [MaxLength(NameMaxLength)] + public string Name { get; set; } = null!; /// /// Description of the to-do list. /// diff --git a/AdvancedTodoList.Core/Services/IEntityExistenceChecker.cs b/AdvancedTodoList.Core/Services/IEntityExistenceChecker.cs new file mode 100644 index 0000000..1893c06 --- /dev/null +++ b/AdvancedTodoList.Core/Services/IEntityExistenceChecker.cs @@ -0,0 +1,25 @@ +using AdvancedTodoList.Core.Models; + +namespace AdvancedTodoList.Core.Services; + +/// +/// An interface for the service that checks whether an entity with an ID exists. +/// +public interface IEntityExistenceChecker +{ + /// + /// Asynchronously checks whether an entity of type with an ID + /// of type exists. + /// + /// Type of the entity. + /// Type which ID of the entity has. + /// ID of the entity which existence is checked. + /// + /// A task representing the asynchronous operation. The task result contains + /// if entity with the given ID exists; otherwise + /// . + /// + Task ExistsAsync(TId id) + where TEntity : class, IEntity + where TId : IEquatable; +} diff --git a/AdvancedTodoList.Core/Services/ITodoItemsService.cs b/AdvancedTodoList.Core/Services/ITodoItemsService.cs new file mode 100644 index 0000000..8aa275b --- /dev/null +++ b/AdvancedTodoList.Core/Services/ITodoItemsService.cs @@ -0,0 +1,91 @@ +using AdvancedTodoList.Core.Dtos; +using AdvancedTodoList.Core.Models.TodoLists; + +namespace AdvancedTodoList.Core.Services; + +/// +/// Interface for a service that manages to-do list items. +/// +public interface ITodoItemsService +{ + /// + /// Retrieves to-do list items of the list with the specified ID. + /// + /// + /// Does not throw exceptions if ID is invalid. + /// + /// The ID of the to-do list which items will be retrieved. + /// + /// A task representing the asynchronous operation. + /// The task result contains a collection of objects. + /// + public Task> GetItemsOfListAsync(string id); + + /// + /// Retrieves a to-do list ID of the to-do list item. + /// + /// ID of the to-do list item. + /// + /// A task representing the asynchronous operation. The task result contains + /// an ID of the to-do list which owns a to-do list item with the specified ID if it's found; + /// otherwise, returns . + /// + public Task GetTodoListByIdAsync(int id); + + /// + /// Retrieves a to-do list item by its ID asynchronously. + /// + /// The ID of the to-do list item to retrieve. + /// + /// A task representing the asynchronous operation. The task result contains + /// a object if the specified ID is found; + /// otherwise, returns . + /// + public Task GetByIdAsync(int id); + + /// + /// Creates a new to-do list item asynchronously. + /// + /// The ID of the to-do list to associate the item with. + /// The DTO containing information for creating the to-do list item. + /// + /// A task representing the asynchronous operation. + /// The task result contains the created . + /// + public Task CreateAsync(string todoListId, TodoItemCreateDto dto); + + /// + /// Edits a to-do list item asynchronously. + /// + /// The ID of the to-do list item to edit. + /// The DTO containing information for editing the to-do list item. + /// + /// A task representing the asynchronous operation. + /// The task result contains on success; + /// otherwise if the entity was not found. + /// + public Task EditAsync(int id, TodoItemCreateDto dto); + + /// + /// Updates the state of a to-do list item asynchronously. + /// + /// The ID of the to-do list item to update the state. + /// The DTO containing information for updating the state of the to-do list item. + /// + /// A task representing the asynchronous operation. + /// The task result contains on success; + /// otherwise if the entity was not found. + /// + public Task UpdateStateAsync(int id, TodoItemUpdateStateDto dto); + + /// + /// Deletes a to-do list item asynchronously. + /// + /// The ID of the to-do list item to delete. + /// + /// A task representing the asynchronous operation. + /// The task result contains on success; + /// otherwise if the entity was not found. + /// + public Task DeleteAsync(int id); +} diff --git a/AdvancedTodoList.Core/Services/ITodoListsService.cs b/AdvancedTodoList.Core/Services/ITodoListsService.cs index 6dd3516..2a13f0d 100644 --- a/AdvancedTodoList.Core/Services/ITodoListsService.cs +++ b/AdvancedTodoList.Core/Services/ITodoListsService.cs @@ -3,12 +3,15 @@ namespace AdvancedTodoList.Core.Services; +/// +/// Interface for a service that manages to-do lists. +/// public interface ITodoListsService { /// - /// Retrieves a to-do list by its unique identifier asynchronously. + /// Retrieves a to-do list by its ID asynchronously. /// - /// The unique identifier of the to-do list to retrieve. + /// The ID of the to-do list to retrieve. /// /// A task representing the asynchronous operation. The task result contains /// a object if the specified ID is found; @@ -19,10 +22,33 @@ public interface ITodoListsService /// /// Creates a new to-do list asynchronously. /// - /// The data transfer object containing information for creating the todo list. + /// The DTO containing information for creating the to-do list. /// /// A task representing the asynchronous operation. /// The task result contains the created . /// public Task CreateAsync(TodoListCreateDto dto); + + /// + /// Edits a to-do list asynchronously. + /// + /// The ID of the to-do list to edit. + /// The DTO containing information for editing the to-do list. + /// + /// A task representing the asynchronous operation. + /// The task result contains on success; + /// otherwise if entity was not found. + /// + public Task EditAsync(string id, TodoListCreateDto dto); + + /// + /// Deletes a to-do list asynchronously. + /// + /// The ID of the to-do list to edit. + /// + /// A task representing the asynchronous operation. + /// The task result contains on success; + /// otherwise if entity was not found. + /// + public Task DeleteAsync(string id); } diff --git a/AdvancedTodoList.Core/Validation/TodoItemCreateDtoValidator.cs b/AdvancedTodoList.Core/Validation/TodoItemCreateDtoValidator.cs new file mode 100644 index 0000000..fb1f535 --- /dev/null +++ b/AdvancedTodoList.Core/Validation/TodoItemCreateDtoValidator.cs @@ -0,0 +1,28 @@ +using AdvancedTodoList.Core.Dtos; +using FluentValidation; + +namespace AdvancedTodoList.Core.Validation; + +/// +/// Validator class for +/// +public class TodoItemCreateDtoValidator : AbstractValidator +{ + public TodoItemCreateDtoValidator() + { + // Name is required + RuleFor(x => x.Name) + .NotEmpty() + .WithErrorCode(ValidationErrorCodes.PropertyRequired); + + // Description is not null + RuleFor(x => x.Description) + .NotNull() + .WithErrorCode(ValidationErrorCodes.PropertyRequired); + + // Deadline date should be after the current date + RuleFor(x => x.DeadlineDate) + .GreaterThan(DateTime.UtcNow) + .WithErrorCode(ValidationErrorCodes.PropertyOutOfRange); + } +} diff --git a/AdvancedTodoList.Core/Validation/TodoItemUpdateStateDtoValidator.cs b/AdvancedTodoList.Core/Validation/TodoItemUpdateStateDtoValidator.cs new file mode 100644 index 0000000..a97368a --- /dev/null +++ b/AdvancedTodoList.Core/Validation/TodoItemUpdateStateDtoValidator.cs @@ -0,0 +1,16 @@ +using AdvancedTodoList.Core.Dtos; +using AdvancedTodoList.Core.Models.TodoLists; +using FluentValidation; + +namespace AdvancedTodoList.Core.Validation; + +public class TodoItemUpdateStateDtoValidator : AbstractValidator +{ + public TodoItemUpdateStateDtoValidator() + { + RuleFor(x => x.State) + .Must(s => s >= TodoItemState.Active && s <= TodoItemState.Skipped) + .WithErrorCode(ValidationErrorCodes.PropertyOutOfRange) + .WithMessage(x => $"{(int)x.State} is invalid value for {{PropertyName}}"); + } +} diff --git a/AdvancedTodoList.Core/Validation/TodoListCreateDtoValidator.cs b/AdvancedTodoList.Core/Validation/TodoListCreateDtoValidator.cs new file mode 100644 index 0000000..fee5486 --- /dev/null +++ b/AdvancedTodoList.Core/Validation/TodoListCreateDtoValidator.cs @@ -0,0 +1,20 @@ +using AdvancedTodoList.Core.Dtos; +using FluentValidation; + +namespace AdvancedTodoList.Core.Validation; + +public class TodoListCreateDtoValidator : AbstractValidator +{ + public TodoListCreateDtoValidator() + { + // Name is required + RuleFor(x => x.Name) + .NotEmpty() + .WithErrorCode(ValidationErrorCodes.PropertyRequired); + + // Description is not null + RuleFor(x => x.Description) + .NotNull() + .WithErrorCode(ValidationErrorCodes.PropertyRequired); + } +} diff --git a/AdvancedTodoList.Core/Validation/ValidationErrorCodes.cs b/AdvancedTodoList.Core/Validation/ValidationErrorCodes.cs new file mode 100644 index 0000000..8a37569 --- /dev/null +++ b/AdvancedTodoList.Core/Validation/ValidationErrorCodes.cs @@ -0,0 +1,24 @@ +namespace AdvancedTodoList.Core.Validation; + +/// +/// Class that contains validation error codes. +/// +public static class ValidationErrorCodes +{ + /// + /// Required property is null or empty. + /// + public const string PropertyRequired = "100"; + /// + /// Length of the property exceeds maximum possible value. + /// + public const string PropertyTooLong = "200"; + /// + /// Value of the property is out of range. + /// + public const string PropertyOutOfRange = "300"; + /// + /// Property is invalid foreign key. + /// + public const string InvalidForeignKey = "400"; +} diff --git a/AdvancedTodoList.Infrastructure/Migrations/20240206171516_InitialSetup.cs b/AdvancedTodoList.Infrastructure/Migrations/20240206171516_InitialSetup.cs index 4ec6ea2..b25b781 100644 --- a/AdvancedTodoList.Infrastructure/Migrations/20240206171516_InitialSetup.cs +++ b/AdvancedTodoList.Infrastructure/Migrations/20240206171516_InitialSetup.cs @@ -1,267 +1,265 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable -namespace AdvancedTodoList.Infrastructure.Migrations +namespace AdvancedTodoList.Infrastructure.Migrations; + +/// +public partial class InitialSetup : Migration { - /// - public partial class InitialSetup : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "AspNetRoles", - columns: table => new - { - Id = table.Column(type: "nvarchar(450)", nullable: false), - Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - NormalizedName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetRoles", x => x.Id); - }); + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + Id = table.Column(type: "nvarchar(450)", nullable: false), + Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); - migrationBuilder.CreateTable( - name: "AspNetUsers", - columns: table => new - { - Id = table.Column(type: "nvarchar(450)", nullable: false), - UserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - NormalizedUserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - NormalizedEmail = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - EmailConfirmed = table.Column(type: "bit", nullable: false), - PasswordHash = table.Column(type: "nvarchar(max)", nullable: true), - SecurityStamp = table.Column(type: "nvarchar(max)", nullable: true), - ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true), - PhoneNumber = table.Column(type: "nvarchar(max)", nullable: true), - PhoneNumberConfirmed = table.Column(type: "bit", nullable: false), - TwoFactorEnabled = table.Column(type: "bit", nullable: false), - LockoutEnd = table.Column(type: "datetimeoffset", nullable: true), - LockoutEnabled = table.Column(type: "bit", nullable: false), - AccessFailedCount = table.Column(type: "int", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUsers", x => x.Id); - }); + migrationBuilder.CreateTable( + name: "AspNetUsers", + columns: table => new + { + Id = table.Column(type: "nvarchar(450)", nullable: false), + UserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "bit", nullable: false), + PasswordHash = table.Column(type: "nvarchar(max)", nullable: true), + SecurityStamp = table.Column(type: "nvarchar(max)", nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumber = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumberConfirmed = table.Column(type: "bit", nullable: false), + TwoFactorEnabled = table.Column(type: "bit", nullable: false), + LockoutEnd = table.Column(type: "datetimeoffset", nullable: true), + LockoutEnabled = table.Column(type: "bit", nullable: false), + AccessFailedCount = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + }); - migrationBuilder.CreateTable( - name: "TodoLists", - columns: table => new - { - Id = table.Column(type: "nvarchar(450)", nullable: false), - Name = table.Column(type: "nvarchar(max)", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_TodoLists", x => x.Id); - }); + migrationBuilder.CreateTable( + name: "TodoLists", + columns: table => new + { + Id = table.Column(type: "nvarchar(450)", nullable: false), + Name = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TodoLists", x => x.Id); + }); - migrationBuilder.CreateTable( - name: "AspNetRoleClaims", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - RoleId = table.Column(type: "nvarchar(450)", nullable: false), - ClaimType = table.Column(type: "nvarchar(max)", nullable: true), - ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); - table.ForeignKey( - name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", - column: x => x.RoleId, - principalTable: "AspNetRoles", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + RoleId = table.Column(type: "nvarchar(450)", nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); - migrationBuilder.CreateTable( - name: "AspNetUserClaims", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - UserId = table.Column(type: "nvarchar(450)", nullable: false), - ClaimType = table.Column(type: "nvarchar(max)", nullable: true), - ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); - table.ForeignKey( - name: "FK_AspNetUserClaims_AspNetUsers_UserId", - column: x => x.UserId, - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserId = table.Column(type: "nvarchar(450)", nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserClaims_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); - migrationBuilder.CreateTable( - name: "AspNetUserLogins", - columns: table => new - { - LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), - ProviderKey = table.Column(type: "nvarchar(450)", nullable: false), - ProviderDisplayName = table.Column(type: "nvarchar(max)", nullable: true), - UserId = table.Column(type: "nvarchar(450)", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); - table.ForeignKey( - name: "FK_AspNetUserLogins_AspNetUsers_UserId", - column: x => x.UserId, - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + columns: table => new + { + LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), + ProviderKey = table.Column(type: "nvarchar(450)", nullable: false), + ProviderDisplayName = table.Column(type: "nvarchar(max)", nullable: true), + UserId = table.Column(type: "nvarchar(450)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_AspNetUserLogins_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); - migrationBuilder.CreateTable( - name: "AspNetUserRoles", - columns: table => new - { - UserId = table.Column(type: "nvarchar(450)", nullable: false), - RoleId = table.Column(type: "nvarchar(450)", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); - table.ForeignKey( - name: "FK_AspNetUserRoles_AspNetRoles_RoleId", - column: x => x.RoleId, - principalTable: "AspNetRoles", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_AspNetUserRoles_AspNetUsers_UserId", - column: x => x.UserId, - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + columns: table => new + { + UserId = table.Column(type: "nvarchar(450)", nullable: false), + RoleId = table.Column(type: "nvarchar(450)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); - migrationBuilder.CreateTable( - name: "AspNetUserTokens", - columns: table => new - { - UserId = table.Column(type: "nvarchar(450)", nullable: false), - LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), - Name = table.Column(type: "nvarchar(450)", nullable: false), - Value = table.Column(type: "nvarchar(max)", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); - table.ForeignKey( - name: "FK_AspNetUserTokens_AspNetUsers_UserId", - column: x => x.UserId, - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + columns: table => new + { + UserId = table.Column(type: "nvarchar(450)", nullable: false), + LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), + Name = table.Column(type: "nvarchar(450)", nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_AspNetUserTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); - migrationBuilder.CreateTable( - name: "TodoItems", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - Name = table.Column(type: "nvarchar(max)", nullable: false), - TodoListId = table.Column(type: "nvarchar(450)", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_TodoItems", x => x.Id); - table.ForeignKey( - name: "FK_TodoItems_TodoLists_TodoListId", - column: x => x.TodoListId, - principalTable: "TodoLists", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); + migrationBuilder.CreateTable( + name: "TodoItems", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(max)", nullable: false), + TodoListId = table.Column(type: "nvarchar(450)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TodoItems", x => x.Id); + table.ForeignKey( + name: "FK_TodoItems_TodoLists_TodoListId", + column: x => x.TodoListId, + principalTable: "TodoLists", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); - migrationBuilder.CreateIndex( - name: "IX_AspNetRoleClaims_RoleId", - table: "AspNetRoleClaims", - column: "RoleId"); + migrationBuilder.CreateIndex( + name: "IX_AspNetRoleClaims_RoleId", + table: "AspNetRoleClaims", + column: "RoleId"); - migrationBuilder.CreateIndex( - name: "RoleNameIndex", - table: "AspNetRoles", - column: "NormalizedName", - unique: true, - filter: "[NormalizedName] IS NOT NULL"); + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "AspNetRoles", + column: "NormalizedName", + unique: true, + filter: "[NormalizedName] IS NOT NULL"); - migrationBuilder.CreateIndex( - name: "IX_AspNetUserClaims_UserId", - table: "AspNetUserClaims", - column: "UserId"); + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + table: "AspNetUserClaims", + column: "UserId"); - migrationBuilder.CreateIndex( - name: "IX_AspNetUserLogins_UserId", - table: "AspNetUserLogins", - column: "UserId"); + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + table: "AspNetUserLogins", + column: "UserId"); - migrationBuilder.CreateIndex( - name: "IX_AspNetUserRoles_RoleId", - table: "AspNetUserRoles", - column: "RoleId"); + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRoles_RoleId", + table: "AspNetUserRoles", + column: "RoleId"); - migrationBuilder.CreateIndex( - name: "EmailIndex", - table: "AspNetUsers", - column: "NormalizedEmail"); + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "AspNetUsers", + column: "NormalizedEmail"); - migrationBuilder.CreateIndex( - name: "UserNameIndex", - table: "AspNetUsers", - column: "NormalizedUserName", - unique: true, - filter: "[NormalizedUserName] IS NOT NULL"); + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true, + filter: "[NormalizedUserName] IS NOT NULL"); - migrationBuilder.CreateIndex( - name: "IX_TodoItems_TodoListId", - table: "TodoItems", - column: "TodoListId"); - } + migrationBuilder.CreateIndex( + name: "IX_TodoItems_TodoListId", + table: "TodoItems", + column: "TodoListId"); + } - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "AspNetRoleClaims"); + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AspNetRoleClaims"); - migrationBuilder.DropTable( - name: "AspNetUserClaims"); + migrationBuilder.DropTable( + name: "AspNetUserClaims"); - migrationBuilder.DropTable( - name: "AspNetUserLogins"); + migrationBuilder.DropTable( + name: "AspNetUserLogins"); - migrationBuilder.DropTable( - name: "AspNetUserRoles"); + migrationBuilder.DropTable( + name: "AspNetUserRoles"); - migrationBuilder.DropTable( - name: "AspNetUserTokens"); + migrationBuilder.DropTable( + name: "AspNetUserTokens"); - migrationBuilder.DropTable( - name: "TodoItems"); + migrationBuilder.DropTable( + name: "TodoItems"); - migrationBuilder.DropTable( - name: "AspNetRoles"); + migrationBuilder.DropTable( + name: "AspNetRoles"); - migrationBuilder.DropTable( - name: "AspNetUsers"); + migrationBuilder.DropTable( + name: "AspNetUsers"); - migrationBuilder.DropTable( - name: "TodoLists"); - } - } + migrationBuilder.DropTable( + name: "TodoLists"); + } } diff --git a/AdvancedTodoList.Infrastructure/Services/EntityExistenceChecker.cs b/AdvancedTodoList.Infrastructure/Services/EntityExistenceChecker.cs new file mode 100644 index 0000000..0aefb2a --- /dev/null +++ b/AdvancedTodoList.Infrastructure/Services/EntityExistenceChecker.cs @@ -0,0 +1,33 @@ +using AdvancedTodoList.Core.Models; +using AdvancedTodoList.Core.Services; +using AdvancedTodoList.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace AdvancedTodoList.Infrastructure.Services; + +/// +/// A service that checks whether an entity with an ID exists. +/// +public class EntityExistenceChecker(ApplicationDbContext dbContext) : IEntityExistenceChecker +{ + private readonly ApplicationDbContext _dbContext = dbContext; + + /// + /// Asynchronously checks whether an entity of type with an ID + /// of type exists. + /// + /// Type of the entity. + /// Type which ID of the entity has. + /// ID of the entity which existence is checked. + /// + /// A task representing the asynchronous operation. The task result contains + /// if entity with the given ID exists; otherwise + /// . + /// + public async Task ExistsAsync(TId id) + where TEntity : class, IEntity + where TId : IEquatable + { + return await _dbContext.Set().AnyAsync(x => x.Id.Equals(id)); + } +} diff --git a/AdvancedTodoList.Infrastructure/Services/TodoItemsService.cs b/AdvancedTodoList.Infrastructure/Services/TodoItemsService.cs new file mode 100644 index 0000000..e151a1c --- /dev/null +++ b/AdvancedTodoList.Infrastructure/Services/TodoItemsService.cs @@ -0,0 +1,160 @@ +using AdvancedTodoList.Core.Dtos; +using AdvancedTodoList.Core.Models.TodoLists; +using AdvancedTodoList.Core.Services; +using AdvancedTodoList.Infrastructure.Data; +using Mapster; +using Microsoft.EntityFrameworkCore; + +namespace AdvancedTodoList.Infrastructure.Services; + +/// +/// A service that manages to-do lists items. +/// +public class TodoItemsService(ApplicationDbContext dbContext) : ITodoItemsService +{ + private readonly ApplicationDbContext _dbContext = dbContext; + + /// + /// Retrieves to-do list items of the list with the specified ID. + /// + /// + /// Does not throw exceptions if ID is invalid. + /// + /// The ID of the to-do list which items will be retrieved. + /// + /// A task representing the asynchronous operation. + /// The task result contains a collection of objects. + /// + public async Task> GetItemsOfListAsync(string id) + { + return await _dbContext.TodoItems + .Where(x => x.TodoListId == id) + .ProjectToType() + .ToListAsync(); + } + + /// + /// Retrieves a to-do list ID of the to-do list item. + /// + /// ID of the to-do list item. + /// + /// A task representing the asynchronous operation. The task result contains + /// an ID of the to-do list which owns a to-do list item with the specified ID if it's found; + /// otherwise, returns . + /// + public async Task GetTodoListByIdAsync(int id) + { + return await _dbContext.TodoItems + .AsNoTracking() + .Where(x => x.Id == id) + .Select(x => x.TodoListId) + .FirstOrDefaultAsync(); + } + + /// + /// Retrieves a to-do list item by its ID asynchronously. + /// + /// The ID of the to-do list item to retrieve. + /// + /// A task representing the asynchronous operation. The task result contains + /// a object if the specified ID is found; + /// otherwise, returns . + /// + public async Task GetByIdAsync(int id) + { + return await _dbContext.TodoItems + .AsNoTracking() + .Where(x => x.Id == id) + .ProjectToType() + .FirstOrDefaultAsync(); + } + + /// + /// Creates a new to-do list item asynchronously. + /// + /// The ID of the to-do list to associate the item with. + /// The DTO containing information for creating the to-do list item. + /// + /// A task representing the asynchronous operation. + /// The task result contains the created . + /// + public async Task CreateAsync(string todoListId, TodoItemCreateDto dto) + { + var item = dto.Adapt(); + item.TodoListId = todoListId; + _dbContext.TodoItems.Add(item); + await _dbContext.SaveChangesAsync(); + return item; + } + + /// + /// Edits a to-do list item asynchronously. + /// + /// The ID of the to-do list item to edit. + /// The DTO containing information for editing the to-do list item. + /// + /// A task representing the asynchronous operation. + /// The task result contains on success; + /// otherwise if the entity was not found. + /// + public async Task EditAsync(int id, TodoItemCreateDto dto) + { + var item = await _dbContext.TodoItems + .Where(x => x.Id == id) + .FirstOrDefaultAsync(); + // To-do item does not exist - return false + if (item == null) return false; + + dto.Adapt(item); + await _dbContext.SaveChangesAsync(); + + return true; + } + + /// + /// Updates the state of a to-do list item asynchronously. + /// + /// The ID of the to-do list item to update the state. + /// The DTO containing information for updating the state of the to-do list item. + /// + /// A task representing the asynchronous operation. + /// The task result contains on success; + /// otherwise if the entity was not found. + /// + public async Task UpdateStateAsync(int id, TodoItemUpdateStateDto dto) + { + var item = await _dbContext.TodoItems + .Where(x => x.Id == id) + .FirstOrDefaultAsync(); + // To-do item does not exist - return false + if (item == null) return false; + + item.State = dto.State; + await _dbContext.SaveChangesAsync(); + + return true; + } + + /// + /// Deletes a to-do list item asynchronously. + /// + /// The ID of the to-do list item to delete. + /// + /// A task representing the asynchronous operation. + /// The task result contains on success; + /// otherwise if the entity was not found. + /// + public async Task DeleteAsync(int id) + { + var item = await _dbContext.TodoItems + .Where(x => x.Id == id) + .FirstOrDefaultAsync(); + // To-do item does not exist - return false + if (item == null) return false; + + _dbContext.TodoItems.Remove(item); + await _dbContext.SaveChangesAsync(); + + return true; + } +} diff --git a/AdvancedTodoList.Infrastructure/Services/TodoListsService.cs b/AdvancedTodoList.Infrastructure/Services/TodoListsService.cs index b328bea..f133267 100644 --- a/AdvancedTodoList.Infrastructure/Services/TodoListsService.cs +++ b/AdvancedTodoList.Infrastructure/Services/TodoListsService.cs @@ -7,14 +7,17 @@ namespace AdvancedTodoList.Infrastructure.Services; +/// +/// A service that manages to-do lists. +/// public class TodoListsService(ApplicationDbContext dbContext) : ITodoListsService { private readonly ApplicationDbContext _dbContext = dbContext; /// - /// Retrieves a to-do list by its unique identifier asynchronously. + /// Retrieves a to-do list by its ID asynchronously. /// - /// The unique identifier of the todo list to retrieve. + /// The ID of the to-do list to retrieve. /// /// A task representing the asynchronous operation. The task result contains /// a object if the specified ID is found; @@ -22,21 +25,17 @@ public class TodoListsService(ApplicationDbContext dbContext) : ITodoListsServic /// public async Task GetByIdAsync(string id) { - var list = await _dbContext.TodoLists - .Include(x => x.TodoItems) + return await _dbContext.TodoLists .AsNoTracking() .Where(x => x.Id == id) + .ProjectToType() .FirstOrDefaultAsync(); - // Todo list does not exist - return null - if (list == null) return null; - - return list.Adapt(); } /// /// Creates a new to-do list asynchronously. /// - /// The data transfer object containing information for creating the to-do list. + /// The DTO containing information for creating the to-do list. /// /// A task representing the asynchronous operation. /// The task result contains the created . @@ -44,9 +43,55 @@ public class TodoListsService(ApplicationDbContext dbContext) : ITodoListsServic public async Task CreateAsync(TodoListCreateDto dto) { var list = dto.Adapt(); - _dbContext.Add(list); + _dbContext.TodoLists.Add(list); await _dbContext.SaveChangesAsync(); return list; } + /// + /// Edits a to-do list asynchronously. + /// + /// The ID of the to-do list to edit. + /// The DTO containing information for editing the to-do list. + /// + /// A task representing the asynchronous operation. + /// The task result contains on success; + /// otherwise if entity was not found. + /// + public async Task EditAsync(string id, TodoListCreateDto dto) + { + var entity = await _dbContext.TodoLists + .Where(x => x.Id == id) + .FirstOrDefaultAsync(); + // To-do list does not exist - return false + if (entity == null) return false; + + dto.Adapt(entity); + await _dbContext.SaveChangesAsync(); + + return true; + } + + /// + /// Deletes a to-do list asynchronously. + /// + /// The ID of the to-do list to edit. + /// + /// A task representing the asynchronous operation. + /// The task result contains on success; + /// otherwise if entity was not found. + /// + public async Task DeleteAsync(string id) + { + var entity = await _dbContext.TodoLists + .Where(x => x.Id == id) + .FirstOrDefaultAsync(); + // To-do list does not exist - return false + if (entity == null) return false; + + _dbContext.TodoLists.Remove(entity); + await _dbContext.SaveChangesAsync(); + + return true; + } } diff --git a/AdvancedTodoList.IntegrationTests/IntegrationTest.cs b/AdvancedTodoList.IntegrationTests/IntegrationTest.cs index dabb93e..2c35c17 100644 --- a/AdvancedTodoList.IntegrationTests/IntegrationTest.cs +++ b/AdvancedTodoList.IntegrationTests/IntegrationTest.cs @@ -1,7 +1,6 @@ using AdvancedTodoList.Infrastructure.Data; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Testcontainers.MsSql; namespace AdvancedTodoList.IntegrationTests; @@ -11,7 +10,7 @@ namespace AdvancedTodoList.IntegrationTests; public abstract class IntegrationTest { private static bool s_migrated = false; - private static MsSqlContainer s_testDbContainer; + protected static TestingWebApplicationFactory WebApplicationFactory { get; private set; } protected IServiceScopeFactory ScopeFactory { get; private set; } protected IServiceScope ServiceScope { get; private set; } @@ -20,6 +19,10 @@ public abstract class IntegrationTest [SetUp] public async Task SetUpServices() { + // Configure web application factory + WebApplicationFactory = new TestingWebApplicationFactory(IntegrationTestsSetup.TestDbContainer); + WebApplicationFactory.Server.PreserveExecutionContext = true; + // Get services needed for integration testing ScopeFactory = WebApplicationFactory.Services.GetService()!; ServiceScope = ScopeFactory.CreateScope(); @@ -38,25 +41,6 @@ public async Task TearDownServicesAsync() // Dispose resources await DbContext.DisposeAsync(); ServiceScope.Dispose(); - } - - [OneTimeSetUp] - public static async Task SetUpIntegrationTestAsync() - { - // Initialize and start a container with test DB - s_testDbContainer = new MsSqlBuilder().Build(); - await s_testDbContainer.StartAsync(); - - // Configure web application factory - WebApplicationFactory = new TestingWebApplicationFactory(s_testDbContainer); - WebApplicationFactory.Server.PreserveExecutionContext = true; - } - - [OneTimeTearDown] - public static async Task TearDownIntegrationTestAsync() - { - // Stop the DB container - await s_testDbContainer.StopAsync(); await WebApplicationFactory.DisposeAsync(); } diff --git a/AdvancedTodoList.IntegrationTests/IntegrationTestsSetup.cs b/AdvancedTodoList.IntegrationTests/IntegrationTestsSetup.cs new file mode 100644 index 0000000..41816a9 --- /dev/null +++ b/AdvancedTodoList.IntegrationTests/IntegrationTestsSetup.cs @@ -0,0 +1,30 @@ +using Testcontainers.MsSql; + +namespace AdvancedTodoList.IntegrationTests; + +/// +/// Class that sets up integration testing environment. +/// +[SetUpFixture] +public static class IntegrationTestsSetup +{ + /// + /// Test container that contains a database. + /// + public static MsSqlContainer TestDbContainer { get; private set; } + + [OneTimeSetUp] + public static async Task SetUpIntegrationTests() + { + // Initialize and start a container with test DB + TestDbContainer = new MsSqlBuilder().Build(); + await TestDbContainer.StartAsync(); + } + + [OneTimeTearDown] + public static async Task TearDownIntegrationTests() + { + // Stop the DB container + await TestDbContainer.StopAsync(); + } +} diff --git a/AdvancedTodoList.IntegrationTests/Services/TodoItemsServiceTests.cs b/AdvancedTodoList.IntegrationTests/Services/TodoItemsServiceTests.cs new file mode 100644 index 0000000..45acc54 --- /dev/null +++ b/AdvancedTodoList.IntegrationTests/Services/TodoItemsServiceTests.cs @@ -0,0 +1,263 @@ +using AdvancedTodoList.Core.Dtos; +using AdvancedTodoList.Core.Models.TodoLists; +using AdvancedTodoList.Core.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace AdvancedTodoList.IntegrationTests.Services; + +/// +/// Tests for endpoints 'api/todo' +/// +public class TodoItemsServiceTests : IntegrationTest +{ + private ITodoItemsService _service; + + // Create a test to-do list and adds it to the DB + private async Task CreateTestListAsync() + { + TodoList testList = new() + { + Name = "Test list", + Description = "A test list for creating a test list item" + }; + DbContext.TodoLists.Add(testList); + await DbContext.SaveChangesAsync(); + DbContext.ChangeTracker.Clear(); + + return testList; + } + + // Create a test to-do list item and adds it to the DB + private async Task CreateTestItemAsync() + { + TodoList testList = await CreateTestListAsync(); + + TodoItem testItem = new() + { + Name = "Test list item", + Description = "Make something cool", + DeadlineDate = null, + State = TodoItemState.Active, + TodoListId = testList.Id + }; + DbContext.TodoItems.Add(testItem); + await DbContext.SaveChangesAsync(); + + DbContext.ChangeTracker.Clear(); + + return testItem; + } + + [SetUp] + public void SetUp() + { + _service = ServiceScope.ServiceProvider.GetService()!; + } + + [Test] + public async Task GetItemsOfListAsync_ListExists_ReturnsEmptyCollection() + { + // Arrange + var list = await CreateTestListAsync(); + var fakeList = await CreateTestListAsync(); + TodoItem[] items = + [ + new TodoItem() { Name = "A", Description = "1", DeadlineDate = null, State = TodoItemState.Active, TodoListId = list.Id }, + new TodoItem() { Name = "B", Description = "2", DeadlineDate = DateTime.UtcNow, State = TodoItemState.Skipped, TodoListId = list.Id }, + new TodoItem() { Name = "C", Description = "3", DeadlineDate = null, State = TodoItemState.Completed, TodoListId = list.Id }, + ]; + DbContext.TodoItems.AddRange(items); + // Add one "fake" item + TodoItem fakeItem = new() { Name = "Fake", Description = "", DeadlineDate = null, State = TodoItemState.Active, TodoListId = fakeList.Id }; + await DbContext.SaveChangesAsync(); + + // Act + var result = await _service.GetItemsOfListAsync(list.Id); + + // Assert + Assert.That(result.Select(x => x.Id), Is.EquivalentTo(items.Select(x => x.Id))); + } + + [Test] + public async Task GetItemsOfListAsync_WrongId_ReturnsEmptyCollection() + { + // Act + var result = await _service.GetItemsOfListAsync("_"); + + // Assert + Assert.That(result, Is.Empty); + } + + [Test] + public async Task GetTodoListByIdAsync_EntityExists_ReturnsCorrectEntity() + { + // Arrange: add test item to the DB + var testItem = await CreateTestItemAsync(); + + // Act: try to obtain a test item's DTO by its ID + string? result = await _service.GetTodoListByIdAsync(testItem.Id); + + // Assert that returned ID matches + Assert.That(result, Is.EqualTo(testItem.TodoListId)); + } + + [Test] + public async Task GetTodoListByIdAsync_EntityDoesNotExist_ReturnsNull() + { + // Act: try to obtain a test item with non-existent ID + var result = await _service.GetTodoListByIdAsync(-1); + + // Assert that null is returned + Assert.That(result, Is.Null); + } + + [Test] + public async Task GetByIdAsync_EntityExists_ReturnsCorrectEntity() + { + // Arrange: add test item to the DB + var testItem = await CreateTestItemAsync(); + + // Act: try to obtain a test item's DTO by its ID + var result = await _service.GetByIdAsync(testItem.Id); + + // Assert that returned DTO matches + Assert.That(result, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(result.Id, Is.EqualTo(testItem.Id)); + Assert.That(result.Name, Is.EqualTo(testItem.Name)); + }); + } + + [Test] + public async Task GetByIdAsync_EntityDoesNotExist_ReturnsNull() + { + // Act: try to obtain a test item with non-existent ID + var result = await _service.GetByIdAsync(-1); + + // Assert that null is returned + Assert.That(result, Is.Null); + } + + [Test] + public async Task CreateAsync_AddsEntityToDb() + { + // Arrange + var list = await CreateTestListAsync(); + TodoItemCreateDto dto = new("Test entity", "...", null); + + // Act: call the method + var result = await _service.CreateAsync(list.Id, dto); + + // Assert that entity was added to the DB + var foundEntity = await DbContext.TodoItems + .AsNoTracking() + .Where(x => x.Id == result.Id) + .SingleOrDefaultAsync(); + Assert.That(foundEntity, Is.Not.Null); + Assert.That(foundEntity.TodoListId, Is.EqualTo(list.Id)); + } + + [Test] + public async Task EditAsync_EntityExists_Succeeds() + { + // Arrange: add test item to the DB + var testItem = await CreateTestItemAsync(); + // Test edit DTO + TodoItemCreateDto dto = new("Edited name", "Edited description", DateTime.UtcNow); + + // Act: edit the item + bool result = await _service.EditAsync(testItem.Id, dto); + + // Assert that result is true + Assert.That(result); + // Assert that item was updated + var updatedItem = await DbContext.TodoItems + .AsNoTracking() + .Where(x => x.Id == testItem.Id) + .FirstAsync(); + Assert.Multiple(() => + { + Assert.That(updatedItem.Name, Is.EqualTo(dto.Name)); + Assert.That(updatedItem.Description, Is.EqualTo(dto.Description)); + Assert.That(updatedItem.DeadlineDate, Is.EqualTo(dto.DeadlineDate)); + }); + } + + [Test] + public async Task EditAsync_EntityDoesNotExist_ReturnsFalse() + { + // Arrange: make a DTO + TodoItemCreateDto dto = new("This", "should not be used if code works properly.", null); + + // Act: try to edit a non-existent item + bool result = await _service.EditAsync(-1, dto); + + // Assert that result is false + Assert.That(result, Is.False); + } + + [Test] + public async Task UpdateStateAsync_EntityExists_Succeeds() + { + // Arrange: add test item to the DB + var testItem = await CreateTestItemAsync(); + // Test DTO + TodoItemUpdateStateDto dto = new(TodoItemState.Completed); + + // Act: update the state of the item + bool result = await _service.UpdateStateAsync(testItem.Id, dto); + + // Assert that result is true + Assert.That(result); + // Assert that item's state was updated + var updatedItem = await DbContext.TodoItems + .AsNoTracking() + .Where(x => x.Id == testItem.Id) + .FirstAsync(); + Assert.That(updatedItem.State, Is.EqualTo(dto.State)); + } + + [Test] + public async Task UpdateStateAsync_EntityDoesNotExist_ReturnsFalse() + { + // Arrange: make a DTO + TodoItemUpdateStateDto dto = new(TodoItemState.Skipped); + + // Act: try to update the state of a non-existent item + bool result = await _service.UpdateStateAsync(-1, dto); + + // Assert that result is false + Assert.That(result, Is.False); + } + + [Test] + public async Task DeleteAsync_EntityExists_Succeeds() + { + // Arrange: add test item to the DB + var testItem = await CreateTestItemAsync(); + + // Act: delete the item + bool result = await _service.DeleteAsync(testItem.Id); + + // Assert that result is true + Assert.That(result); + // Assert that an item was deleted + var actualList = await DbContext.TodoItems + .AsNoTracking() + .Where(x => x.Id == testItem.Id) + .FirstOrDefaultAsync(); + Assert.That(actualList, Is.Null); + } + + [Test] + public async Task DeleteAsync_EntityDoesNotExist_ReturnsFalse() + { + // Act: try to delete a non-existent item + bool result = await _service.DeleteAsync(-1); + + // Assert that result is false + Assert.That(result, Is.False); + } +} diff --git a/AdvancedTodoList.IntegrationTests/Services/TodoListServiceTests.cs b/AdvancedTodoList.IntegrationTests/Services/TodoListServiceTests.cs deleted file mode 100644 index 8f488a4..0000000 --- a/AdvancedTodoList.IntegrationTests/Services/TodoListServiceTests.cs +++ /dev/null @@ -1,70 +0,0 @@ -using AdvancedTodoList.Core.Dtos; -using AdvancedTodoList.Core.Models.TodoLists; -using AdvancedTodoList.Core.Services; -using Mapster; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; - -namespace AdvancedTodoList.IntegrationTests.Services; - -[TestFixture] -public class TodoListServiceTests : IntegrationTest -{ - private ITodoListsService _service; - - [SetUp] - public void SetUp() - { - _service = ServiceScope.ServiceProvider.GetService()!; - } - - [Test] - public async Task GetByIdAsync_EntityExists_ReturnsCorrectEntity() - { - // Arrange: add test list to the DB - TodoList testList = new() - { - Name = "Test list" - }; - DbContext.TodoLists.Add(testList); - await DbContext.SaveChangesAsync(); - - // Act: try to obtain a test list's DTO by its ID - var result = await _service.GetByIdAsync(testList.Id); - - // Assert that returned DTO matches - Assert.That(result, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(result.Id, Is.EqualTo(testList.Id)); - Assert.That(result.Name, Is.EqualTo(testList.Name)); - }); - } - - [Test] - public async Task GetByIdAsync_EntityDoesNotExist_ReturnsNull() - { - // Act: try to obtain a test list with non existent ID - var result = await _service.GetByIdAsync("_"); - - // Assert that null is returned - Assert.That(result, Is.Null); - } - - [Test] - public async Task CreateAsync_AddsEntityToDb() - { - // Arrange: initialize a DTO - TodoListCreateDto dto = new("Test entity"); - - // Act: call the method - var result = await _service.CreateAsync(dto); - - // Assert that entity was added to the DB - var foundEntity = await DbContext.TodoLists - .AsNoTracking() - .Where(x => x.Id == result.Id) - .SingleOrDefaultAsync(); - Assert.That(foundEntity, Is.Not.Null); - } -} diff --git a/AdvancedTodoList.IntegrationTests/Services/TodoListsServiceTests.cs b/AdvancedTodoList.IntegrationTests/Services/TodoListsServiceTests.cs new file mode 100644 index 0000000..29ca05e --- /dev/null +++ b/AdvancedTodoList.IntegrationTests/Services/TodoListsServiceTests.cs @@ -0,0 +1,146 @@ +using AdvancedTodoList.Core.Dtos; +using AdvancedTodoList.Core.Models.TodoLists; +using AdvancedTodoList.Core.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace AdvancedTodoList.IntegrationTests.Services; + +[TestFixture] +public class TodoListsServiceTests : IntegrationTest +{ + private ITodoListsService _service; + + // Create a test to-do list and adds it to the DB + private async Task CreateTestListAsync() + { + TodoList testList = new() + { + Name = "Test list", + Description = "" + }; + DbContext.TodoLists.Add(testList); + await DbContext.SaveChangesAsync(); + DbContext.ChangeTracker.Clear(); + + return testList; + } + + [SetUp] + public void SetUp() + { + _service = ServiceScope.ServiceProvider.GetService()!; + } + + [Test] + public async Task GetByIdAsync_EntityExists_ReturnsCorrectEntity() + { + // Arrange: add test list to the DB + var testList = await CreateTestListAsync(); + + // Act: try to obtain a test list's DTO by its ID + var result = await _service.GetByIdAsync(testList.Id); + + // Assert that returned DTO matches + Assert.That(result, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(result.Id, Is.EqualTo(testList.Id)); + Assert.That(result.Name, Is.EqualTo(testList.Name)); + }); + } + + [Test] + public async Task GetByIdAsync_EntityDoesNotExist_ReturnsNull() + { + // Act: try to obtain a test list with non-existent ID + var result = await _service.GetByIdAsync("_"); + + // Assert that null is returned + Assert.That(result, Is.Null); + } + + [Test] + public async Task CreateAsync_AddsEntityToDb() + { + // Arrange: initialize a DTO + TodoListCreateDto dto = new("Test entity", "..."); + + // Act: call the method + var result = await _service.CreateAsync(dto); + + // Assert that entity was added to the DB + var foundEntity = await DbContext.TodoLists + .AsNoTracking() + .Where(x => x.Id == result.Id) + .SingleOrDefaultAsync(); + Assert.That(foundEntity, Is.Not.Null); + } + + [Test] + public async Task EditAsync_EntityExists_Succeeds() + { + // Arrange: add test list to the DB + var testList = await CreateTestListAsync(); + // Test edit DTO + TodoListCreateDto dto = new("Edited name", "Edited description"); + + // Act: edit the list + bool result = await _service.EditAsync(testList.Id, dto); + + // Assert that result is true + Assert.That(result); + // Assert that list was updated + var updatedList = await DbContext.TodoLists + .AsNoTracking() + .Where(x => x.Id == testList.Id) + .FirstAsync(); + Assert.Multiple(() => + { + Assert.That(updatedList.Name, Is.EqualTo(dto.Name)); + Assert.That(updatedList.Description, Is.EqualTo(dto.Description)); + }); + } + + [Test] + public async Task EditAsync_EntityDoesNotExist_ReturnsFalse() + { + // Arrange: make a DTO + TodoListCreateDto dto = new("This", "should not be used if code works properly."); + + // Act: try to edit a non-existent list + bool result = await _service.EditAsync("_", dto); + + // Assert that result is false + Assert.That(result, Is.False); + } + + [Test] + public async Task DeleteAsync_EntityExists_Succeeds() + { + // Arrange: add test list to the DB + var testList = await CreateTestListAsync(); + + // Act: delete the list + bool result = await _service.DeleteAsync(testList.Id); + + // Assert that result is true + Assert.That(result); + // Assert that list was deleted + var actualList = await DbContext.TodoLists + .AsNoTracking() + .Where(x => x.Id == testList.Id) + .FirstOrDefaultAsync(); + Assert.That(actualList, Is.Null); + } + + [Test] + public async Task DeleteAsync_EntityDoesNotExist_ReturnsFalse() + { + // Act: try to delete a non-existent list + bool result = await _service.DeleteAsync("_"); + + // Assert that result is false + Assert.That(result, Is.False); + } +} diff --git a/AdvancedTodoList.RouteTests/GlobalUsings.cs b/AdvancedTodoList.RouteTests/GlobalUsings.cs index 803d05e..3e4da1d 100644 --- a/AdvancedTodoList.RouteTests/GlobalUsings.cs +++ b/AdvancedTodoList.RouteTests/GlobalUsings.cs @@ -1,2 +1,2 @@ +global using NSubstitute; global using NUnit.Framework; -global using NSubstitute; \ No newline at end of file diff --git a/AdvancedTodoList.RouteTests/RouteTestsWebApplicationFactory.cs b/AdvancedTodoList.RouteTests/RouteTestsWebApplicationFactory.cs index 034626e..50cbee3 100644 --- a/AdvancedTodoList.RouteTests/RouteTestsWebApplicationFactory.cs +++ b/AdvancedTodoList.RouteTests/RouteTestsWebApplicationFactory.cs @@ -1,27 +1,29 @@ using AdvancedTodoList.Core.Services; -using AdvancedTodoList.Infrastructure.Data; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using NSubstitute; -using System.Data.Common; namespace AdvancedTodoList.RouteTests; public class RouteTestsWebApplicationFactory : WebApplicationFactory { public ITodoListsService TodoListsService { get; private set; } = null!; + public ITodoItemsService TodoItemsService { get; private set; } = null!; + public IEntityExistenceChecker EntityExistenceChecker { get; private set; } = null!; protected override void ConfigureWebHost(IWebHostBuilder builder) { - // Create mocks for services + // Create mocks for the services TodoListsService = Substitute.For(); + TodoItemsService = Substitute.For(); + EntityExistenceChecker = Substitute.For(); builder.ConfigureTestServices(services => { services.AddScoped(_ => TodoListsService); + services.AddScoped(_ => TodoItemsService); + services.AddScoped(_ => EntityExistenceChecker); }); } } diff --git a/AdvancedTodoList.RouteTests/Tests/TodoItemsEndpointsTests.cs b/AdvancedTodoList.RouteTests/Tests/TodoItemsEndpointsTests.cs new file mode 100644 index 0000000..4428075 --- /dev/null +++ b/AdvancedTodoList.RouteTests/Tests/TodoItemsEndpointsTests.cs @@ -0,0 +1,421 @@ +using AdvancedTodoList.Core.Dtos; +using AdvancedTodoList.Core.Models.TodoLists; +using AdvancedTodoList.RouteTests; +using NSubstitute.ReturnsExtensions; +using System.Net; +using System.Net.Http.Json; + +namespace AdvancedTodoItem.RouteTests.Tests; + +/// +/// Tests for endpoints 'api/todo/{listId}/items' +/// +public class TodoItemsEndpointsTests : RouteTest +{ + [Test] + public async Task GetTodoItems_ListExists_SucceedsAndReturnsItems() + { + // Arrange + string testListId = "TestId"; + TodoItemPreviewDto[] collection = + [ + new(124013, testListId, "1", null, TodoItemState.Active), + new(124124, testListId, "2", null, TodoItemState.Completed) + ]; + + WebApplicationFactory.EntityExistenceChecker + .ExistsAsync(testListId) + .Returns(true); + WebApplicationFactory.TodoItemsService + .GetItemsOfListAsync(testListId) + .Returns(collection); + using HttpClient client = WebApplicationFactory.CreateClient(); + + // Act: send the request + var result = await client.GetAsync($"api/todo/{testListId}/items/"); + + // Assert that response indicates success + result.EnsureSuccessStatusCode(); + // Assert that valid collection was returned + var returnedCollection = await result.Content.ReadFromJsonAsync(); + Assert.That(returnedCollection, Is.Not.Null); + Assert.That(returnedCollection, Is.EquivalentTo(collection)); + } + + [Test] + public async Task GetTodoItems_ListDoesNotExist_Returns404() + { + // Arrange + string testListId = "TestId"; + + WebApplicationFactory.EntityExistenceChecker + .ExistsAsync(testListId) + .Returns(false); + using HttpClient client = WebApplicationFactory.CreateClient(); + + // Act: send the request + var result = await client.GetAsync($"api/todo/{testListId}/items/"); + + // Assert that response code is 404 + Assert.That(result.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } + + [Test] + public async Task GetTodoItemById_ElementExists_ReturnsElement() + { + // Arrange + string testListId = "TestId"; + int testItemId = 777; + TodoItemGetByIdDto testDto = new(testItemId, testListId, "Test todo item", "...", null, TodoItemState.Active); + + WebApplicationFactory.TodoItemsService + .GetByIdAsync(testItemId) + .Returns(testDto); + using HttpClient client = WebApplicationFactory.CreateClient(); + + // Act: send the request + var result = await client.GetAsync($"api/todo/{testListId}/items/{testItemId}"); + + // Assert that response indicates success + result.EnsureSuccessStatusCode(); + // Assert that valid object was returned + var returnedDto = await result.Content.ReadFromJsonAsync(); + Assert.That(returnedDto, Is.Not.Null); + Assert.That(returnedDto, Is.EqualTo(testDto)); + } + + [Test] + public async Task GetTodoItemById_ElementDoesNotExist_Returns404() + { + // Arrange + string testListId = "TestId"; + int testItemId = 777; + WebApplicationFactory.TodoItemsService + .GetByIdAsync(testItemId) + .ReturnsNull(); + using HttpClient client = WebApplicationFactory.CreateClient(); + + // Act: send the request + var result = await client.GetAsync($"api/todo/{testListId}/items/{testItemId}"); + + // Assert that response code is 404 + Assert.That(result.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } + + [Test] + public async Task GetTodoItemById_WrongTodoListId_Returns404() + { + // Arrange + string testListId = "TestId"; + int testItemId = 777; + TodoItemGetByIdDto testDto = new(testItemId, "RealListId", "Test todo item", "...", null, TodoItemState.Active); + WebApplicationFactory.TodoItemsService + .GetByIdAsync(testItemId) + .Returns(testDto); + using HttpClient client = WebApplicationFactory.CreateClient(); + + // Act: send the request + var result = await client.GetAsync($"api/todo/{testListId}/items/{testItemId}"); + + // Assert that response code is 404 + Assert.That(result.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } + + [Test] + public async Task PostTodoItem_ValidCall_Succeeds() + { + // Arrange + string listId = "ListId"; + WebApplicationFactory.EntityExistenceChecker + .ExistsAsync(listId) + .Returns(true); + WebApplicationFactory.TodoItemsService + .CreateAsync(Arg.Any(), Arg.Any()) + .Returns(new TodoItem() { Id = 13480 }); + TodoItemCreateDto dto = new("Item", "...", DateTime.MaxValue); + using HttpClient client = WebApplicationFactory.CreateClient(); + + // Act: send the request + var result = await client.PostAsJsonAsync($"api/todo/{listId}/items", dto); + + // Assert that response indicates success + result.EnsureSuccessStatusCode(); + // Assert that create method was called + await WebApplicationFactory.TodoItemsService + .Received() + .CreateAsync(listId, dto); + } + + [Test] + public async Task PostTodoItem_InvalidDto_Returns400() + { + // Arrange + string listId = "ListId"; + TodoItemCreateDto invalidDto = new(string.Empty, string.Empty, DateTime.MinValue); + using HttpClient client = WebApplicationFactory.CreateClient(); + + // Act: send the request + var result = await client.PostAsJsonAsync($"api/todo/{listId}/items", invalidDto); + + // Assert that response code is 400 + Assert.That(result.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + } + + [Test] + public async Task PostTodoItem_TodoListDoesNotExist_Returns404() + { + // Arrange + string listId = "ListId"; + WebApplicationFactory.EntityExistenceChecker + .ExistsAsync(listId) + .Returns(false); + TodoItemCreateDto dto = new("Item", "...", DateTime.MaxValue); + using HttpClient client = WebApplicationFactory.CreateClient(); + + // Act: send the request + var result = await client.PostAsJsonAsync($"api/todo/{listId}/items", dto); + + // Assert that response code is 404 + Assert.That(result.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } + + [Test] + public async Task PutTodoItem_ElementExists_Succeeds() + { + // Arrange + string testListId = "TestId"; + int testItemId = 891349; + TodoItemCreateDto dto = new("Do nothing for entire day", "...", DateTime.MaxValue); + + WebApplicationFactory.TodoItemsService + .GetTodoListByIdAsync(testItemId) + .Returns(testListId); + WebApplicationFactory.TodoItemsService + .EditAsync(testItemId, dto) + .Returns(true); + using HttpClient client = WebApplicationFactory.CreateClient(); + + // Act: send the request + var result = await client.PutAsJsonAsync($"api/todo/{testListId}/items/{testItemId}", dto); + + // Assert that response indicates success + result.EnsureSuccessStatusCode(); + // Assert that edit was called + await WebApplicationFactory.TodoItemsService + .Received() + .EditAsync(testItemId, dto); + } + + [Test] + public async Task PutTodoItem_InvalidDto_Returns400() + { + // Arrange + string testListId = "TestId"; + int testItemId = 891349; + TodoItemCreateDto invalidDto = new(string.Empty, string.Empty, DateTime.MinValue); + using HttpClient client = WebApplicationFactory.CreateClient(); + + // Act: send the request + var result = await client.PutAsJsonAsync($"api/todo/{testListId}/items/{testItemId}", invalidDto); + + // Assert that response code is 400 + Assert.That(result.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + } + + [Test] + public async Task PutTodoItem_WrongTodoListId_Returns404() + { + // Arrange + string testListId = "TestId"; + int testItemId = 891349; + TodoItemCreateDto dto = new("Do nothing for entire day", "...", DateTime.MaxValue); + + WebApplicationFactory.TodoItemsService + .GetTodoListByIdAsync(testItemId) + .Returns("WrongId"); + using HttpClient client = WebApplicationFactory.CreateClient(); + + // Act: send the request + var result = await client.PutAsJsonAsync($"api/todo/{testListId}/items/{testItemId}", dto); + + // Assert that response code is 404 + Assert.That(result.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } + + [Test] + public async Task PutTodoItem_ElementDoesNotExist_Returns404() + { + // Arrange + string testListId = "TestId"; + int testItemId = 12412; + TodoItemCreateDto dto = new("New name", "New description", null); + + WebApplicationFactory.TodoItemsService + .GetTodoListByIdAsync(testItemId) + .Returns(testListId); + WebApplicationFactory.TodoItemsService + .EditAsync(testItemId, dto) + .Returns(false); + using HttpClient client = WebApplicationFactory.CreateClient(); + + // Act: send the request + var result = await client.PutAsJsonAsync($"api/todo/{testListId}/items/{testItemId}", dto); + + // Assert that response code is 404 + Assert.That(result.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } + + [Test] + public async Task PutTodoItemState_ElementExists_Succeeds() + { + // Arrange + string testListId = "TestId"; + int testItemId = 891349; + TodoItemUpdateStateDto dto = new(TodoItemState.Completed); + + WebApplicationFactory.TodoItemsService + .GetTodoListByIdAsync(testItemId) + .Returns(testListId); + WebApplicationFactory.TodoItemsService + .UpdateStateAsync(testItemId, dto) + .Returns(true); + using HttpClient client = WebApplicationFactory.CreateClient(); + + // Act: send the request + var result = await client.PutAsJsonAsync($"api/todo/{testListId}/items/{testItemId}/state", dto); + + // Assert that response indicates success + result.EnsureSuccessStatusCode(); + // Assert that edit was called + await WebApplicationFactory.TodoItemsService + .Received() + .UpdateStateAsync(testItemId, dto); + } + + [Test] + public async Task PutTodoItemState_InvalidDto_Returns400() + { + // Arrange + string testListId = "TestId"; + int testItemId = 891349; + TodoItemUpdateStateDto dto = new((TodoItemState)213); + + using HttpClient client = WebApplicationFactory.CreateClient(); + + // Act: send the request + var result = await client.PutAsJsonAsync($"api/todo/{testListId}/items/{testItemId}/state", dto); + + // Assert that response code is 400 + Assert.That(result.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + } + + [Test] + public async Task PutTodoItemState_WrongTodoListId_Returns404() + { + // Arrange + string testListId = "TestId"; + int testItemId = 891349; + TodoItemUpdateStateDto dto = new(TodoItemState.Skipped); + + WebApplicationFactory.TodoItemsService + .GetTodoListByIdAsync(testItemId) + .Returns("WrongId"); + using HttpClient client = WebApplicationFactory.CreateClient(); + + // Act: send the request + var result = await client.PutAsJsonAsync($"api/todo/{testListId}/items/{testItemId}/state", dto); + + // Assert that response code is 404 + Assert.That(result.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } + + [Test] + public async Task PutTodoItemState_ElementDoesNotExist_Returns404() + { + // Arrange + string testListId = "TestId"; + int testItemId = 12412; + TodoItemUpdateStateDto dto = new(TodoItemState.Skipped); + + WebApplicationFactory.TodoItemsService + .GetTodoListByIdAsync(testItemId) + .Returns(testListId); + WebApplicationFactory.TodoItemsService + .UpdateStateAsync(testItemId, dto) + .Returns(false); + using HttpClient client = WebApplicationFactory.CreateClient(); + + // Act: send the request + var result = await client.PutAsJsonAsync($"api/todo/{testListId}/items/{testItemId}/state", dto); + + // Assert that response code is 404 + Assert.That(result.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } + + [Test] + public async Task DeleteTodoItem_ElementExists_Succeeds() + { + // Arrange + string testListId = "TestId"; + int testItemId = 504030; + + WebApplicationFactory.TodoItemsService + .GetTodoListByIdAsync(testItemId) + .Returns(testListId); + WebApplicationFactory.TodoItemsService + .DeleteAsync(testItemId) + .Returns(true); + using HttpClient client = WebApplicationFactory.CreateClient(); + + // Act: send the request + var result = await client.DeleteAsync($"api/todo/{testListId}/items/{testItemId}"); + + // Assert that response indicates success + result.EnsureSuccessStatusCode(); + // Assert that delete was called + await WebApplicationFactory.TodoItemsService + .Received() + .DeleteAsync(testItemId); + } + + [Test] + public async Task DeleteTodoItem_ElementDoesNotExist_Returns404() + { + // Arrange + string testListId = "TestId"; + int testItemId = 504030; + + WebApplicationFactory.TodoItemsService + .GetTodoListByIdAsync(testItemId) + .Returns(testListId); + WebApplicationFactory.TodoItemsService + .DeleteAsync(testItemId) + .Returns(false); + using HttpClient client = WebApplicationFactory.CreateClient(); + + // Act: send the request + var result = await client.DeleteAsync($"api/todo/{testListId}/items/{testItemId}"); + + // Assert that response code is 404 + Assert.That(result.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } + + [Test] + public async Task DeleteTodoItem_WrongTodoListId_Returns404() + { + // Arrange + string testListId = "TestId"; + int testItemId = 504030; + + WebApplicationFactory.TodoItemsService + .GetTodoListByIdAsync(testItemId) + .Returns("WrongId"); + using HttpClient client = WebApplicationFactory.CreateClient(); + + // Act: send the request + var result = await client.DeleteAsync($"api/todo/{testListId}/items/{testItemId}"); + + // Assert that response code is 404 + Assert.That(result.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } +} diff --git a/AdvancedTodoList.RouteTests/Tests/TodoListsEndpointsTests.cs b/AdvancedTodoList.RouteTests/Tests/TodoListsEndpointsTests.cs index e3ba1b9..07888d4 100644 --- a/AdvancedTodoList.RouteTests/Tests/TodoListsEndpointsTests.cs +++ b/AdvancedTodoList.RouteTests/Tests/TodoListsEndpointsTests.cs @@ -14,11 +14,10 @@ public async Task GetTodoListById_ElementExists_ReturnsElement() { // Arrange string testId = "TestId"; - TodoListGetByIdDto testDto = new(testId, "Test todo list", []); + TodoListGetByIdDto testDto = new(testId, "Test todo list", ""); WebApplicationFactory.TodoListsService .GetByIdAsync(testId) .Returns(testDto); - TodoListCreateDto dto = new("Test"); using HttpClient client = WebApplicationFactory.CreateClient(); // Act: send the request @@ -29,11 +28,7 @@ public async Task GetTodoListById_ElementExists_ReturnsElement() // Assert that valid object was returned var returnedDto = await result.Content.ReadFromJsonAsync(); Assert.That(returnedDto, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(returnedDto.Id, Is.EqualTo(testDto.Id)); - Assert.That(returnedDto.Name, Is.EqualTo(testDto.Name)); - }); + Assert.That(returnedDto, Is.EqualTo(testDto)); } [Test] @@ -60,7 +55,7 @@ public async Task PostTodoList_ValidCall_Succeeds() WebApplicationFactory.TodoListsService .CreateAsync(Arg.Any()) .Returns(new TodoList() { Id = "TestID" }); - TodoListCreateDto dto = new("Test"); + TodoListCreateDto dto = new("Test", string.Empty); using HttpClient client = WebApplicationFactory.CreateClient(); // Act: send the request @@ -73,4 +68,115 @@ await WebApplicationFactory.TodoListsService .Received() .CreateAsync(dto); } + + [Test] + public async Task PostTodoList_InvalidDto_Returns400() + { + // Arrange + TodoListCreateDto invalidDto = new(string.Empty, string.Empty); + using HttpClient client = WebApplicationFactory.CreateClient(); + + // Act: send the request + var result = await client.PostAsJsonAsync("api/todo", invalidDto); + + // Assert that response code is 400 + Assert.That(result.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + } + + [Test] + public async Task PutTodoList_ElementExists_Succeeds() + { + // Arrange + string testId = "TestId"; + TodoListCreateDto dto = new("New name", "New description"); + + WebApplicationFactory.TodoListsService + .EditAsync(testId, dto) + .Returns(true); + using HttpClient client = WebApplicationFactory.CreateClient(); + + // Act: send the request + var result = await client.PutAsJsonAsync($"api/todo/{testId}", dto); + + // Assert that response indicates success + result.EnsureSuccessStatusCode(); + // Assert that edit was called + await WebApplicationFactory.TodoListsService + .Received() + .EditAsync(testId, dto); + } + + [Test] + public async Task PutTodoList_InvalidDto_Returns400() + { + // Arrange + string testId = "TestId"; + TodoListCreateDto invalidDto = new(string.Empty, string.Empty); + using HttpClient client = WebApplicationFactory.CreateClient(); + + // Act: send the request + var result = await client.PutAsJsonAsync($"api/todo/{testId}", invalidDto); + + // Assert that response code is 400 + Assert.That(result.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + } + + [Test] + public async Task PutTodoList_ElementDoesNotExist_Returns404() + { + // Arrange + string testId = "TestId"; + TodoListCreateDto dto = new("New name", "New description"); + + WebApplicationFactory.TodoListsService + .EditAsync(testId, dto) + .Returns(false); + using HttpClient client = WebApplicationFactory.CreateClient(); + + // Act: send the request + var result = await client.PutAsJsonAsync($"api/todo/{testId}", dto); + + // Assert that response code is 404 + Assert.That(result.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } + + [Test] + public async Task DeleteTodoList_ElementExists_Succeeds() + { + // Arrange + string testId = "TestId"; + + WebApplicationFactory.TodoListsService + .DeleteAsync(testId) + .Returns(true); + using HttpClient client = WebApplicationFactory.CreateClient(); + + // Act: send the request + var result = await client.DeleteAsync($"api/todo/{testId}"); + + // Assert that response indicates success + result.EnsureSuccessStatusCode(); + // Assert that delete was called + await WebApplicationFactory.TodoListsService + .Received() + .DeleteAsync(testId); + } + + [Test] + public async Task DeleteTodoList_ElementDoesNotExist_Returns404() + { + // Arrange + string testId = "TestId"; + + WebApplicationFactory.TodoListsService + .DeleteAsync(testId) + .Returns(false); + using HttpClient client = WebApplicationFactory.CreateClient(); + + // Act: send the request + var result = await client.DeleteAsync($"api/todo/{testId}"); + + // Assert that response code is 404 + Assert.That(result.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } } diff --git a/AdvancedTodoList.UnitTests/GlobalUsings.cs b/AdvancedTodoList.UnitTests/GlobalUsings.cs index cefced4..97651f3 100644 --- a/AdvancedTodoList.UnitTests/GlobalUsings.cs +++ b/AdvancedTodoList.UnitTests/GlobalUsings.cs @@ -1 +1,3 @@ -global using NUnit.Framework; \ No newline at end of file +global using FluentValidation; +global using FluentValidation.TestHelper; +global using NUnit.Framework; diff --git a/AdvancedTodoList.UnitTests/Mapping/InputSanitizingTests.cs b/AdvancedTodoList.UnitTests/Mapping/InputSanitizingTests.cs new file mode 100644 index 0000000..acc520c --- /dev/null +++ b/AdvancedTodoList.UnitTests/Mapping/InputSanitizingTests.cs @@ -0,0 +1,57 @@ +using AdvancedTodoList.Core.Mapping; +using Mapster; + +namespace AdvancedTodoList.UnitTests.Mapping; + +[TestFixture] +public class InputSanitizingTests +{ + private class Poco + { + public string? Text { get; set; } + public DateTime Date { get; set; } + public DateTime? NullableDate { get; set; } + } + + private record TextDto(string Text); + private record DateDto(DateTime Date, DateTime? NullableDate); + + [SetUp] + public void SetUp() + { + MappingGlobalSettings.Apply(); + } + + [Test] + public void NullString_MapsIntoEmptyString() + { + // Arrange + Poco poco = new() + { + Text = null + }; + + // Act + var dto = poco.Adapt(); + + // Assert + Assert.That(dto.Text, Is.EqualTo(string.Empty)); + } + + [Test] + public void String_MapsIntoTrimmedString() + { + // Arrange + string expectedText = "Lorem ipsum dolor sit amet, ..."; + Poco poco = new() + { + Text = $"\t\r {expectedText} \t\t\r " + }; + + // Act + var dto = poco.Adapt(); + + // Assert + Assert.That(dto.Text, Is.EqualTo(expectedText)); + } +} diff --git a/AdvancedTodoList.UnitTests/TestTests.cs b/AdvancedTodoList.UnitTests/TestTests.cs deleted file mode 100644 index bc597ab..0000000 --- a/AdvancedTodoList.UnitTests/TestTests.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace AdvancedTodoList.UnitTests; - -public class Tests -{ - [Test] - public void TestTest() - { - Assert.Pass(); - } -} \ No newline at end of file diff --git a/AdvancedTodoList.UnitTests/Validation/TodoItemCreateDtoValidatorTests.cs b/AdvancedTodoList.UnitTests/Validation/TodoItemCreateDtoValidatorTests.cs new file mode 100644 index 0000000..5ee28ea --- /dev/null +++ b/AdvancedTodoList.UnitTests/Validation/TodoItemCreateDtoValidatorTests.cs @@ -0,0 +1,93 @@ +using AdvancedTodoList.Core.Dtos; +using AdvancedTodoList.Core.Validation; +using Microsoft.VisualStudio.TestPlatform.ObjectModel; + +namespace AdvancedTodoList.UnitTests.Validation; + +[TestFixture] +public class TodoItemCreateDtoValidatorTests +{ + private const string ValidName = "Valid"; + private const string ValidDescription = "Valid description"; + private static DateTime? ValidDeadline => DateTime.UtcNow + TimeSpan.FromDays(14); + + [Test] + public void Dto_ValidData_Succeeds() + { + // Arrange + TodoItemCreateDtoValidator validator = new(); + TodoItemCreateDto dto = new(ValidName, ValidDescription, ValidDeadline); + + // Act + var result = validator.TestValidate(dto); + + // Assert + result.ShouldNotHaveAnyValidationErrors(); + } + + [Test] + [TestCase("")] + [TestCase(" ")] + [TestCase("\r\t \t\t\r")] + [TestCase(null)] + public void Name_Empty_ReturnsPropertyRequiredError(string testCase) + { + // Arrange + TodoItemCreateDtoValidator validator = new(); + TodoItemCreateDto dto = new(testCase, ValidDescription, ValidDeadline); + + // Act + var result = validator.TestValidate(dto); + + // Assert + result.ShouldHaveValidationErrorFor(x => x.Name) + .WithErrorCode(ValidationErrorCodes.PropertyRequired); + } + + [Test] + public void Description_Null_ReturnsPropertyRequiredError() + { + // Arrange + TodoItemCreateDtoValidator validator = new(); + TodoItemCreateDto dto = new(ValidName, null!, ValidDeadline); + + // Act + var result = validator.TestValidate(dto); + + // Assert + result.ShouldHaveValidationErrorFor(x => x.Description) + .WithErrorCode(ValidationErrorCodes.PropertyRequired); + } + + [Test] + [TestCase("")] + [TestCase(" ")] + [TestCase("\r\t \t\t\r")] + public void Description_EmptyAndNotNull_IsAllowed(string testCase) + { + // Arrange + TodoItemCreateDtoValidator validator = new(); + TodoItemCreateDto dto = new(ValidName, testCase, ValidDeadline); + + // Act + var result = validator.TestValidate(dto); + + // Assert + result.ShouldNotHaveValidationErrorFor(x => x.Description); + } + + [Test] + public void DeadlineDate_BeforeCurrentDate_ReturnsPropertyOutOfRangeError() + { + // Arrange + TodoItemCreateDtoValidator validator = new(); + TodoItemCreateDto dto = new(ValidName, ValidDescription, DateTime.UtcNow - TimeSpan.FromDays(1)); + + // Act + var result = validator.TestValidate(dto); + + // Assert + result.ShouldHaveValidationErrorFor(x => x.DeadlineDate) + .WithErrorCode(ValidationErrorCodes.PropertyOutOfRange); + } +} diff --git a/AdvancedTodoList.UnitTests/Validation/TodoItemUpdateStateDtoValidatorTests.cs b/AdvancedTodoList.UnitTests/Validation/TodoItemUpdateStateDtoValidatorTests.cs new file mode 100644 index 0000000..41ccf03 --- /dev/null +++ b/AdvancedTodoList.UnitTests/Validation/TodoItemUpdateStateDtoValidatorTests.cs @@ -0,0 +1,44 @@ +using AdvancedTodoList.Core.Dtos; +using AdvancedTodoList.Core.Models.TodoLists; +using AdvancedTodoList.Core.Validation; + +namespace AdvancedTodoList.UnitTests.Validation; + +[TestFixture] +public class TodoItemUpdateStateDtoValidatorTests +{ + [Test] + [TestCase(TodoItemState.Active)] + [TestCase(TodoItemState.Completed)] + [TestCase(TodoItemState.Skipped)] + public void State_Valid_Succeeds(TodoItemState testCase) + { + // Arrange + TodoItemUpdateStateDtoValidator validator = new(); + TodoItemUpdateStateDto dto = new(testCase); + + // Act + var result = validator.TestValidate(dto); + + // Assert + result.ShouldNotHaveAnyValidationErrors(); + } + + [Test] + [TestCase((TodoItemState)6)] + [TestCase((TodoItemState)124)] + [TestCase((TodoItemState)200)] + public void State_OutOfRange_ReturnsPropertyOutOfRangeError(TodoItemState testCase) + { + // Arrange + TodoItemUpdateStateDtoValidator validator = new(); + TodoItemUpdateStateDto dto = new(testCase); + + // Act + var result = validator.TestValidate(dto); + + // Assert + result.ShouldHaveValidationErrorFor(x => x.State) + .WithErrorCode(ValidationErrorCodes.PropertyOutOfRange); + } +} diff --git a/AdvancedTodoList.UnitTests/Validation/TodoListCreateDtoValidatorTests.cs b/AdvancedTodoList.UnitTests/Validation/TodoListCreateDtoValidatorTests.cs new file mode 100644 index 0000000..fc28885 --- /dev/null +++ b/AdvancedTodoList.UnitTests/Validation/TodoListCreateDtoValidatorTests.cs @@ -0,0 +1,76 @@ +using AdvancedTodoList.Core.Dtos; +using AdvancedTodoList.Core.Validation; + +namespace AdvancedTodoList.UnitTests.Validation; + +[TestFixture] +public class TodoListCreateDtoValidatorTests +{ + private const string ValidName = "Valid"; + private const string ValidDescription = "Valid description"; + + [Test] + public void Dto_ValidData_Succeeds() + { + // Arrange + TodoListCreateDtoValidator validator = new(); + TodoListCreateDto dto = new(ValidName, ValidDescription); + + // Act + var result = validator.TestValidate(dto); + + // Assert + result.ShouldNotHaveAnyValidationErrors(); + } + + [Test] + [TestCase("")] + [TestCase(" ")] + [TestCase("\r\t \t\t\r")] + [TestCase(null)] + public void Name_Empty_ReturnsPropertyRequiredError(string testCase) + { + // Arrange + TodoListCreateDtoValidator validator = new(); + TodoListCreateDto dto = new(testCase, ValidDescription); + + // Act + var result = validator.TestValidate(dto); + + // Assert + result.ShouldHaveValidationErrorFor(x => x.Name) + .WithErrorCode(ValidationErrorCodes.PropertyRequired); + } + + [Test] + public void Description_Null_ReturnsPropertyRequiredError() + { + // Arrange + TodoListCreateDtoValidator validator = new(); + TodoListCreateDto dto = new(ValidName, null!); + + // Act + var result = validator.TestValidate(dto); + + // Assert + result.ShouldHaveValidationErrorFor(x => x.Description) + .WithErrorCode(ValidationErrorCodes.PropertyRequired); + } + + [Test] + [TestCase("")] + [TestCase(" ")] + [TestCase("\r\t \t\t\r")] + public void Description_EmptyAndNotNull_IsAllowed(string testCase) + { + // Arrange + TodoListCreateDtoValidator validator = new(); + TodoListCreateDto dto = new(ValidName, testCase); + + // Act + var result = validator.TestValidate(dto); + + // Assert + result.ShouldNotHaveValidationErrorFor(x => x.Description); + } +} diff --git a/AdvancedTodoList/AdvancedTodoList.csproj b/AdvancedTodoList/AdvancedTodoList.csproj index 2fcb60e..3ec5a8b 100644 --- a/AdvancedTodoList/AdvancedTodoList.csproj +++ b/AdvancedTodoList/AdvancedTodoList.csproj @@ -8,10 +8,12 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/AdvancedTodoList/Controllers/TodoController.cs b/AdvancedTodoList/Controllers/TodoController.cs new file mode 100644 index 0000000..e18730c --- /dev/null +++ b/AdvancedTodoList/Controllers/TodoController.cs @@ -0,0 +1,200 @@ +using AdvancedTodoList.Core.Dtos; +using AdvancedTodoList.Core.Models.TodoLists; +using AdvancedTodoList.Core.Services; +using Mapster; +using Microsoft.AspNetCore.Mvc; + +namespace AdvancedTodoList.Controllers; + +[ApiController] +[Route("api/todo")] +public class TodoController( + ITodoListsService todoListsService, ITodoItemsService todoItemsService, + IEntityExistenceChecker existenceChecker) : ControllerBase +{ + private readonly ITodoListsService _todoListsService = todoListsService; + private readonly ITodoItemsService _todoItemsService = todoItemsService; + private readonly IEntityExistenceChecker _existenceChecker = existenceChecker; + + #region Lists + /// + /// Gets a to-do list by its ID. + /// + /// ID of the to-do list to obtain. + /// Returns requested to-do list. + /// To-do list was not found. + [HttpGet("{listId}", Name = nameof(GetTodoListByIdAsync))] + [ProducesResponseType(typeof(TodoListGetByIdDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetTodoListByIdAsync([FromRoute] string listId) + { + var list = await _todoListsService.GetByIdAsync(listId); + return list != null ? Ok(list) : NotFound(); + } + + /// + /// Creates a new to-do list. + /// + /// Successfully created. + [HttpPost] + [ProducesResponseType(typeof(TodoListGetByIdDto), StatusCodes.Status201Created)] + public async Task PostTodoListAsync([FromBody] TodoListCreateDto dto) + { + var list = await _todoListsService.CreateAsync(dto); + var routeValues = new { listId = list.Id }; + var body = list.Adapt(); + return CreatedAtRoute(nameof(GetTodoListByIdAsync), routeValues, body); + } + + /// + /// Updates a to-do list. + /// + /// Success. + /// To-do list was not found. + [HttpPut("{listId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task PutTodoListAsync( + [FromRoute] string listId, [FromBody] TodoListCreateDto dto) + { + bool result = await _todoListsService.EditAsync(listId, dto); + return result ? NoContent() : NotFound(); + } + + /// + /// Deletes a to-do list. + /// + /// Success. + /// To-do list was not found. + [HttpDelete("{listId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeleteTodoListAsync([FromRoute] string listId) + { + bool result = await _todoListsService.DeleteAsync(listId); + return result ? NoContent() : NotFound(); + } + #endregion + + #region Items + // Check if listId matches with an actual list ID + private async Task CheckListIdAsync(string listId, int itemId) + { + return listId == (await _todoItemsService.GetTodoListByIdAsync(itemId)); + } + + /// + /// Gets items of the to-do list with the specified ID. + /// + /// ID of the to-do list. + /// Returns items of the to-do list. + /// To-do list was not found. + [HttpGet("{listId}/items", Name = nameof(GetListItemsAsync))] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetListItemsAsync([FromRoute] string listId) + { + // Check if list exists + if (!await _existenceChecker.ExistsAsync(listId)) + return NotFound(); + + return Ok(await _todoItemsService.GetItemsOfListAsync(listId)); + } + + /// + /// Gets a to-do list item by its ID. + /// + /// ID of the to-do list which contans the item to obtain. + /// ID of the to-do list item to obtain. + /// Returns requested to-do list item. + /// To-do list item was not found. + [HttpGet("{listId}/items/{itemId}", Name = nameof(GetTodoItemByIdAsync))] + [ProducesResponseType(typeof(TodoItemGetByIdDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetTodoItemByIdAsync( + [FromRoute] string listId, [FromRoute] int itemId) + { + var item = await _todoItemsService.GetByIdAsync(itemId); + // Check if item exists and has valid to-do list ID + if (item == null || item.TodoListId != listId) return NotFound(); + + return Ok(item); + } + + /// + /// Creates a new to-do list item. + /// + /// ID of the to-do list which will contain the item. + /// + /// Successfully created. + /// To-do list was not found. + [HttpPost("{listId}/items")] + [ProducesResponseType(typeof(TodoItemGetByIdDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task PostTodoItemAsync( + [FromRoute] string listId, [FromBody] TodoItemCreateDto dto) + { + // Check if list exists + if (!await _existenceChecker.ExistsAsync(listId)) + return NotFound(); + + var item = await _todoItemsService.CreateAsync(listId, dto); + var routeValues = new { listId, itemId = item.Id }; + var body = item.Adapt(); + return CreatedAtRoute(nameof(GetTodoItemByIdAsync), routeValues, body); + } + + /// + /// Updates a to-do list item. + /// + /// Success. + /// To-do list item was not found. + [HttpPut("{listId}/items/{itemId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task PutTodoItemAsync( + [FromRoute] string listId, [FromRoute] int itemId, + [FromBody] TodoItemCreateDto dto) + { + if (!await CheckListIdAsync(listId, itemId)) return NotFound(); + + bool result = await _todoItemsService.EditAsync(itemId, dto); + return result ? NoContent() : NotFound(); + } + + /// + /// Updates a to-do list item's state. + /// + /// Success. + /// To-do list item was not found. + [HttpPut("{listId}/items/{itemId}/state")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task PutTodoItemStateAsync( + [FromRoute] string listId, [FromRoute] int itemId, + [FromBody] TodoItemUpdateStateDto dto) + { + if (!await CheckListIdAsync(listId, itemId)) return NotFound(); + + bool result = await _todoItemsService.UpdateStateAsync(itemId, dto); + return result ? NoContent() : NotFound(); + } + + /// + /// Deletes a to-do list item. + /// + /// Success. + /// To-do list item was not found. + [HttpDelete("{listId}/items/{itemId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeleteTodoListAsync( + [FromRoute] string listId, [FromRoute] int itemId) + { + if (!await CheckListIdAsync(listId, itemId)) return NotFound(); + + bool result = await _todoItemsService.DeleteAsync(itemId); + return result ? NoContent() : NotFound(); + } + #endregion +} diff --git a/AdvancedTodoList/Controllers/TodoListsController.cs b/AdvancedTodoList/Controllers/TodoListsController.cs deleted file mode 100644 index a1bf7d8..0000000 --- a/AdvancedTodoList/Controllers/TodoListsController.cs +++ /dev/null @@ -1,42 +0,0 @@ -using AdvancedTodoList.Core.Dtos; -using AdvancedTodoList.Core.Services; -using Mapster; -using Microsoft.AspNetCore.Mvc; - -namespace AdvancedTodoList.Controllers; - -[ApiController] -[Route("api/todo")] -public class TodoListsController(ITodoListsService todoListsService) : ControllerBase -{ - private readonly ITodoListsService _todoListsService = todoListsService; - - /// - /// Gets a to-do list by its ID. - /// - /// ID of the to-do list to obtain. - /// Returns requested to-do list. - /// To-do list was not found. - [HttpGet("{listId}", Name = nameof(GetTodoListByIdAsync))] - [ProducesResponseType(typeof(TodoListGetByIdDto), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task GetTodoListByIdAsync(string listId) - { - var list = await _todoListsService.GetByIdAsync(listId); - return list != null ? Ok(list) : NotFound(); - } - - /// - /// Creates a new to-do list. - /// - /// Successfully created. - [HttpPost] - [ProducesResponseType(typeof(TodoListGetByIdDto), StatusCodes.Status201Created)] - public async Task PostTodoListAsync([FromBody] TodoListCreateDto dto) - { - var list = await _todoListsService.CreateAsync(dto); - var routeValues = new { listId = list.Id }; - var body = list.Adapt(); - return CreatedAtRoute(nameof(GetTodoListByIdAsync), routeValues, body); - } -} diff --git a/AdvancedTodoList/Program.cs b/AdvancedTodoList/Program.cs index e628eac..2593df0 100644 --- a/AdvancedTodoList/Program.cs +++ b/AdvancedTodoList/Program.cs @@ -1,9 +1,13 @@ +using AdvancedTodoList.Core.Mapping; using AdvancedTodoList.Core.Models; using AdvancedTodoList.Core.Services; +using AdvancedTodoList.Core.Validation; using AdvancedTodoList.Infrastructure.Data; using AdvancedTodoList.Infrastructure.Services; +using FluentValidation; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; +using SharpGrip.FluentValidation.AutoValidation.Mvc.Extensions; using System.Text.Json.Serialization; var builder = WebApplication.CreateBuilder(args); @@ -42,6 +46,27 @@ // Register application services builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Apply mapping settings +MappingGlobalSettings.Apply(); + +// Add fluent validation +ValidatorOptions.Global.LanguageManager.Enabled = false; // Disable localization +builder.Services.AddValidatorsFromAssemblyContaining(); + +// Enable auto validation by SharpGrip +builder.Services.AddFluentValidationAutoValidation(configuration => +{ + // Disable the built-in .NET model (data annotations) validation. + configuration.DisableBuiltInModelValidation = true; + // Enable validation for parameters bound from `BindingSource.Body` binding sources. + configuration.EnableBodyBindingSourceAutomaticValidation = true; + // Enable validation for parameters bound from `BindingSource.Query` binding sources. + configuration.EnableQueryBindingSourceAutomaticValidation = true; +}); + var app = builder.Build();