diff --git a/AdvancedTodoList.Core/Dtos/TodoListDtos.cs b/AdvancedTodoList.Core/Dtos/TodoListDtos.cs index e30e653..9627710 100644 --- a/AdvancedTodoList.Core/Dtos/TodoListDtos.cs +++ b/AdvancedTodoList.Core/Dtos/TodoListDtos.cs @@ -8,4 +8,4 @@ public record TodoListCreateDto(string Name, string Description); /// /// DTO for a full view of a to-do list. /// -public record TodoListGetByIdDto(string Id, string Name, string Description); +public record TodoListGetByIdDto(string Id, string Name, string Description, ApplicationUserPreviewDto Owner); diff --git a/AdvancedTodoList.Core/Dtos/TodoListItemDtos.cs b/AdvancedTodoList.Core/Dtos/TodoListItemDtos.cs index e44c370..8491ed7 100644 --- a/AdvancedTodoList.Core/Dtos/TodoListItemDtos.cs +++ b/AdvancedTodoList.Core/Dtos/TodoListItemDtos.cs @@ -18,7 +18,7 @@ public record TodoItemUpdateStateDto(TodoItemState State); public record TodoItemGetByIdDto( int Id, string TodoListId, string Name, string Description, DateTime? DeadlineDate, - TodoItemState State + TodoItemState State, ApplicationUserPreviewDto Owner ); /// diff --git a/AdvancedTodoList.Core/Models/TodoLists/Members/RolePermissions.cs b/AdvancedTodoList.Core/Models/TodoLists/Members/RolePermissions.cs index edbcb9d..424e1d6 100644 --- a/AdvancedTodoList.Core/Models/TodoLists/Members/RolePermissions.cs +++ b/AdvancedTodoList.Core/Models/TodoLists/Members/RolePermissions.cs @@ -20,4 +20,10 @@ public record struct RolePermissions( bool RemoveMembers = false, bool AssignRoles = false, bool EditRoles = false - ); + ) +{ + /// + /// Instance of a structure with all permissions. + /// + public static readonly RolePermissions All = new(true, true, true, true, true, true, true, true); +} diff --git a/AdvancedTodoList.Core/Models/TodoLists/TodoItem.cs b/AdvancedTodoList.Core/Models/TodoLists/TodoItem.cs index 262f1a2..a19dd58 100644 --- a/AdvancedTodoList.Core/Models/TodoLists/TodoItem.cs +++ b/AdvancedTodoList.Core/Models/TodoLists/TodoItem.cs @@ -1,4 +1,5 @@ -using System.ComponentModel.DataAnnotations; +using AdvancedTodoList.Core.Models.Auth; +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace AdvancedTodoList.Core.Models.TodoLists; @@ -37,6 +38,20 @@ public class TodoItem : IEntity, ITodoListDependant /// [ForeignKey(nameof(TodoList))] public required string TodoListId { get; set; } + /// + /// Navigation property to the to-do list associated with this to-do item. + /// + public TodoList TodoList { get; set; } = null!; + + /// + /// Foreign key referencing the user who created this item. + /// + [ForeignKey(nameof(Owner))] + public required string? OwnerId { get; set; } = null!; + /// + /// Navigation property to the user who created this item. + /// + public ApplicationUser? Owner { get; set; } /// /// Maximum allowed length of . @@ -46,11 +61,6 @@ public class TodoItem : IEntity, ITodoListDependant /// 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!; } /// diff --git a/AdvancedTodoList.Core/Models/TodoLists/TodoList.cs b/AdvancedTodoList.Core/Models/TodoLists/TodoList.cs index f115df4..b8b5dd8 100644 --- a/AdvancedTodoList.Core/Models/TodoLists/TodoList.cs +++ b/AdvancedTodoList.Core/Models/TodoLists/TodoList.cs @@ -1,4 +1,5 @@ -using System.ComponentModel.DataAnnotations; +using AdvancedTodoList.Core.Models.Auth; +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace AdvancedTodoList.Core.Models.TodoLists; @@ -26,6 +27,16 @@ public class TodoList : IEntity [MaxLength(DescriptionMaxLength)] public required string Description { get; set; } = null!; + /// + /// Foreign key referencing the user who created this to-do list. + /// + [ForeignKey(nameof(Owner))] + public required string? OwnerId { get; set; } = null!; + /// + /// Navigation property to the user who created this to-do list. + /// + public ApplicationUser? Owner { get; set; } + /// /// Maximum allowed length of . /// diff --git a/AdvancedTodoList.Core/Services/ITodoItemsService.cs b/AdvancedTodoList.Core/Services/ITodoItemsService.cs index e32e3f8..84bcf9c 100644 --- a/AdvancedTodoList.Core/Services/ITodoItemsService.cs +++ b/AdvancedTodoList.Core/Services/ITodoItemsService.cs @@ -38,13 +38,14 @@ public interface ITodoItemsService /// /// The ID of the to-do list to associate the item with. /// The DTO containing information for creating the to-do list item. + /// ID of the user who creates the to-do list item. /// /// A task representing the asynchronous operation. /// The task result contains the created mapped to /// or if to-do list with ID /// does not exist. /// - public Task CreateAsync(string todoListId, TodoItemCreateDto dto); + public Task CreateAsync(string todoListId, TodoItemCreateDto dto, string callerId); /// /// Edits a to-do list item asynchronously. diff --git a/AdvancedTodoList.Core/Services/ITodoListsService.cs b/AdvancedTodoList.Core/Services/ITodoListsService.cs index b39d8b1..fd5762e 100644 --- a/AdvancedTodoList.Core/Services/ITodoListsService.cs +++ b/AdvancedTodoList.Core/Services/ITodoListsService.cs @@ -22,13 +22,17 @@ public interface ITodoListsService /// /// Creates a new to-do list asynchronously. /// + /// + /// This method should also create an "Owner" role with all permissions and assign the caller to it. + /// /// The DTO containing information for creating the to-do list. + /// ID of the user who creates the to-do list. /// /// A task representing the asynchronous operation. /// The task result contains the created mapped to /// . /// - public Task CreateAsync(TodoListCreateDto dto); + public Task CreateAsync(TodoListCreateDto dto, string callerId); /// /// Edits a to-do list asynchronously. diff --git a/AdvancedTodoList.Infrastructure/Migrations/20240302182741_AddListsAndItemsOwners.Designer.cs b/AdvancedTodoList.Infrastructure/Migrations/20240302182741_AddListsAndItemsOwners.Designer.cs new file mode 100644 index 0000000..39a33fb --- /dev/null +++ b/AdvancedTodoList.Infrastructure/Migrations/20240302182741_AddListsAndItemsOwners.Designer.cs @@ -0,0 +1,544 @@ +// +using System; +using System.Collections.Generic; +using AdvancedTodoList.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace AdvancedTodoList.Infrastructure.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20240302182741_AddListsAndItemsOwners")] + partial class AddListsAndItemsOwners + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("AdvancedTodoList.Core.Models.Auth.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("AdvancedTodoList.Core.Models.Auth.UserRefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Token") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("ValidTo") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserRefreshTokens"); + }); + + modelBuilder.Entity("AdvancedTodoList.Core.Models.TodoLists.Members.TodoListMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("RoleId") + .HasColumnType("int"); + + b.Property("TodoListId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("TodoListId"); + + b.HasIndex("UserId"); + + b.ToTable("TodoListsMembers"); + }); + + modelBuilder.Entity("AdvancedTodoList.Core.Models.TodoLists.Members.TodoListRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.Property("TodoListId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.ComplexProperty>("Permissions", "AdvancedTodoList.Core.Models.TodoLists.Members.TodoListRole.Permissions#RolePermissions", b1 => + { + b1.Property("AddItems") + .HasColumnType("bit"); + + b1.Property("AddMembers") + .HasColumnType("bit"); + + b1.Property("AssignRoles") + .HasColumnType("bit"); + + b1.Property("DeleteItems") + .HasColumnType("bit"); + + b1.Property("EditItems") + .HasColumnType("bit"); + + b1.Property("EditRoles") + .HasColumnType("bit"); + + b1.Property("RemoveMembers") + .HasColumnType("bit"); + + b1.Property("SetItemsState") + .HasColumnType("bit"); + }); + + b.HasKey("Id"); + + b.HasIndex("TodoListId"); + + b.ToTable("TodoListRoles"); + }); + + modelBuilder.Entity("AdvancedTodoList.Core.Models.TodoLists.TodoItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DeadlineDate") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OwnerId") + .HasColumnType("nvarchar(450)"); + + b.Property("State") + .HasColumnType("tinyint"); + + b.Property("TodoListId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.HasIndex("TodoListId"); + + b.ToTable("TodoItems"); + }); + + modelBuilder.Entity("AdvancedTodoList.Core.Models.TodoLists.TodoList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("nvarchar(450)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(25000) + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OwnerId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("TodoLists"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("AdvancedTodoList.Core.Models.Auth.UserRefreshToken", b => + { + b.HasOne("AdvancedTodoList.Core.Models.Auth.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AdvancedTodoList.Core.Models.TodoLists.Members.TodoListMember", b => + { + b.HasOne("AdvancedTodoList.Core.Models.TodoLists.Members.TodoListRole", "Role") + .WithMany() + .HasForeignKey("RoleId"); + + b.HasOne("AdvancedTodoList.Core.Models.TodoLists.TodoList", "TodoList") + .WithMany() + .HasForeignKey("TodoListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("AdvancedTodoList.Core.Models.Auth.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("TodoList"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AdvancedTodoList.Core.Models.TodoLists.Members.TodoListRole", b => + { + b.HasOne("AdvancedTodoList.Core.Models.TodoLists.TodoList", "TodoList") + .WithMany() + .HasForeignKey("TodoListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TodoList"); + }); + + modelBuilder.Entity("AdvancedTodoList.Core.Models.TodoLists.TodoItem", b => + { + b.HasOne("AdvancedTodoList.Core.Models.Auth.ApplicationUser", "Owner") + .WithMany() + .HasForeignKey("OwnerId"); + + b.HasOne("AdvancedTodoList.Core.Models.TodoLists.TodoList", "TodoList") + .WithMany("TodoItems") + .HasForeignKey("TodoListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + + b.Navigation("TodoList"); + }); + + modelBuilder.Entity("AdvancedTodoList.Core.Models.TodoLists.TodoList", b => + { + b.HasOne("AdvancedTodoList.Core.Models.Auth.ApplicationUser", "Owner") + .WithMany() + .HasForeignKey("OwnerId"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("AdvancedTodoList.Core.Models.Auth.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("AdvancedTodoList.Core.Models.Auth.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("AdvancedTodoList.Core.Models.Auth.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("AdvancedTodoList.Core.Models.Auth.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AdvancedTodoList.Core.Models.TodoLists.TodoList", b => + { + b.Navigation("TodoItems"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/AdvancedTodoList.Infrastructure/Migrations/20240302182741_AddListsAndItemsOwners.cs b/AdvancedTodoList.Infrastructure/Migrations/20240302182741_AddListsAndItemsOwners.cs new file mode 100644 index 0000000..4185255 --- /dev/null +++ b/AdvancedTodoList.Infrastructure/Migrations/20240302182741_AddListsAndItemsOwners.cs @@ -0,0 +1,78 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AdvancedTodoList.Infrastructure.Migrations +{ + /// + public partial class AddListsAndItemsOwners : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "OwnerId", + table: "TodoLists", + type: "nvarchar(450)", + nullable: true); + + migrationBuilder.AddColumn( + name: "OwnerId", + table: "TodoItems", + type: "nvarchar(450)", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_TodoLists_OwnerId", + table: "TodoLists", + column: "OwnerId"); + + migrationBuilder.CreateIndex( + name: "IX_TodoItems_OwnerId", + table: "TodoItems", + column: "OwnerId"); + + migrationBuilder.AddForeignKey( + name: "FK_TodoItems_AspNetUsers_OwnerId", + table: "TodoItems", + column: "OwnerId", + principalTable: "AspNetUsers", + principalColumn: "Id"); + + migrationBuilder.AddForeignKey( + name: "FK_TodoLists_AspNetUsers_OwnerId", + table: "TodoLists", + column: "OwnerId", + principalTable: "AspNetUsers", + principalColumn: "Id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_TodoItems_AspNetUsers_OwnerId", + table: "TodoItems"); + + migrationBuilder.DropForeignKey( + name: "FK_TodoLists_AspNetUsers_OwnerId", + table: "TodoLists"); + + migrationBuilder.DropIndex( + name: "IX_TodoLists_OwnerId", + table: "TodoLists"); + + migrationBuilder.DropIndex( + name: "IX_TodoItems_OwnerId", + table: "TodoItems"); + + migrationBuilder.DropColumn( + name: "OwnerId", + table: "TodoLists"); + + migrationBuilder.DropColumn( + name: "OwnerId", + table: "TodoItems"); + } + } +} diff --git a/AdvancedTodoList.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/AdvancedTodoList.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs index 74abd52..6f44eb9 100644 --- a/AdvancedTodoList.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/AdvancedTodoList.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs @@ -229,6 +229,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(100) .HasColumnType("nvarchar(100)"); + b.Property("OwnerId") + .HasColumnType("nvarchar(450)"); + b.Property("State") .HasColumnType("tinyint"); @@ -238,6 +241,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("OwnerId"); + b.HasIndex("TodoListId"); b.ToTable("TodoItems"); @@ -259,8 +264,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(100) .HasColumnType("nvarchar(100)"); + b.Property("OwnerId") + .HasColumnType("nvarchar(450)"); + b.HasKey("Id"); + b.HasIndex("OwnerId"); + b.ToTable("TodoLists"); }); @@ -446,15 +456,30 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("AdvancedTodoList.Core.Models.TodoLists.TodoItem", b => { + b.HasOne("AdvancedTodoList.Core.Models.Auth.ApplicationUser", "Owner") + .WithMany() + .HasForeignKey("OwnerId"); + b.HasOne("AdvancedTodoList.Core.Models.TodoLists.TodoList", "TodoList") .WithMany("TodoItems") .HasForeignKey("TodoListId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + b.Navigation("Owner"); + b.Navigation("TodoList"); }); + modelBuilder.Entity("AdvancedTodoList.Core.Models.TodoLists.TodoList", b => + { + b.HasOne("AdvancedTodoList.Core.Models.Auth.ApplicationUser", "Owner") + .WithMany() + .HasForeignKey("OwnerId"); + + b.Navigation("Owner"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) diff --git a/AdvancedTodoList.Infrastructure/Services/TodoItemsService.cs b/AdvancedTodoList.Infrastructure/Services/TodoItemsService.cs index c1f3aca..c09f521 100644 --- a/AdvancedTodoList.Infrastructure/Services/TodoItemsService.cs +++ b/AdvancedTodoList.Infrastructure/Services/TodoItemsService.cs @@ -1,17 +1,25 @@ using AdvancedTodoList.Core.Dtos; using AdvancedTodoList.Core.Models.TodoLists; using AdvancedTodoList.Core.Pagination; +using AdvancedTodoList.Core.Repositories; using AdvancedTodoList.Core.Services; using AdvancedTodoList.Infrastructure.Specifications; +using Mapster; namespace AdvancedTodoList.Infrastructure.Services; /// /// A service that manages to-do lists items. /// -public class TodoItemsService(ITodoListDependantEntitiesService helperService) : ITodoItemsService +public class TodoItemsService( + ITodoListDependantEntitiesService helperService, + IRepository repository, + IEntityExistenceChecker existenceChecker + ) : ITodoItemsService { private readonly ITodoListDependantEntitiesService _helperService = helperService; + private readonly IRepository _repository = repository; + private readonly IEntityExistenceChecker _existenceChecker = existenceChecker; /// /// Retrieves a page of to-do list items of the list with the specified ID. @@ -39,9 +47,15 @@ public class TodoItemsService(ITodoListDependantEntitiesService h /// a object if the specified ID is found; /// otherwise, returns . /// - public Task GetByIdAsync(string todoListId, int itemId) + public async Task GetByIdAsync(string todoListId, int itemId) { - return _helperService.GetByIdAsync(todoListId, itemId); + TodoItemAggregateSpecification specification = new(itemId); + // Get the aggregate + var dto = await _repository.GetAggregateAsync(specification); + // Check if it's valid + if (dto == null || dto.TodoListId != todoListId) return null; + + return dto; } /// @@ -55,9 +69,22 @@ public class TodoItemsService(ITodoListDependantEntitiesService h /// or if to-do list with ID /// does not exist. /// - public Task CreateAsync(string todoListId, TodoItemCreateDto dto) + public async Task CreateAsync(string todoListId, TodoItemCreateDto dto, string callerId) { - return _helperService.CreateAsync(todoListId, dto); + // Check if to-do list exists + if (!await _existenceChecker.ExistsAsync(todoListId)) + return null; + + // Create the item + var todoItem = dto.Adapt(); + // Set the foreign key + todoItem.TodoListId = todoListId; + // Set the owner + todoItem.OwnerId = callerId; + // Save it + await _repository.AddAsync(todoItem); + // Map it to DTO and return + return todoItem.Adapt(); } /// diff --git a/AdvancedTodoList.Infrastructure/Services/TodoListsService.cs b/AdvancedTodoList.Infrastructure/Services/TodoListsService.cs index ff3614f..aa3a304 100644 --- a/AdvancedTodoList.Infrastructure/Services/TodoListsService.cs +++ b/AdvancedTodoList.Infrastructure/Services/TodoListsService.cs @@ -1,7 +1,9 @@ using AdvancedTodoList.Core.Dtos; using AdvancedTodoList.Core.Models.TodoLists; +using AdvancedTodoList.Core.Models.TodoLists.Members; using AdvancedTodoList.Core.Repositories; using AdvancedTodoList.Core.Services; +using AdvancedTodoList.Infrastructure.Specifications; using Mapster; namespace AdvancedTodoList.Infrastructure.Services; @@ -9,9 +11,17 @@ namespace AdvancedTodoList.Infrastructure.Services; /// /// A service that manages to-do lists. /// -public class TodoListsService(IRepository repository) : ITodoListsService +public class TodoListsService( + IRepository todoListsRepository, + IRepository rolesRepository, + ITodoListMembersRepository membersRepository, + IUnitOfWork unitOfWork + ) : ITodoListsService { - private readonly IRepository _repository = repository; + private readonly IRepository _todoListsRepository = todoListsRepository; + private readonly IRepository _rolesRepository = rolesRepository; + private readonly IRepository _membersRepository = membersRepository; + private readonly IUnitOfWork _unitOfWork = unitOfWork; /// /// Retrieves a to-do list by its ID asynchronously. @@ -22,29 +32,69 @@ public class TodoListsService(IRepository repository) : ITodoL /// a object if the specified ID is found; /// otherwise, returns . /// - public async Task GetByIdAsync(string id) + public Task GetByIdAsync(string id) { - var todoList = await _repository.GetByIdAsync(id); - if (todoList == null) return null; - - return todoList.Adapt(); + TodoListAggregateSpecification specification = new(id); + return _todoListsRepository.GetAggregateAsync(specification); } /// /// Creates a new to-do list asynchronously. /// + /// + /// This method also creates an "Owner" role with all permissions and assigns the caller to it. + /// /// The DTO containing information for creating the to-do list. + /// ID of the user who creates the to-do list. /// /// A task representing the asynchronous operation. /// The task result contains the created mapped to /// . /// - public async Task CreateAsync(TodoListCreateDto dto) + public async Task CreateAsync(TodoListCreateDto dto, string callerId) { - // Map DTO to model + // Map DTO to the model var todoList = dto.Adapt(); - // Add model to the database - await _repository.AddAsync(todoList); + // Set the owner + todoList.OwnerId = callerId; + + // Begin a transaction + await _unitOfWork.BeginTransactionAsync(); + + try + { + // Add the list to the database + await _todoListsRepository.AddAsync(todoList); + + // Create an "Owner" role + TodoListRole ownerRole = new() + { + Name = "Owner", + Priority = 0, + TodoListId = todoList.Id, + Permissions = RolePermissions.All + }; + await _rolesRepository.AddAsync(ownerRole); + + // Assign the caller to it + TodoListMember member = new() + { + UserId = callerId, + TodoListId = todoList.Id, + RoleId = ownerRole.Id + }; + await _membersRepository.AddAsync(member); + } + catch (Exception) + { + // Rollback in a case of error + await _unitOfWork.RollbackAsync(); + throw; + } + + // Commit changes + await _unitOfWork.CommitAsync(); + // Return DTO of created model return todoList.Adapt(); } @@ -62,13 +112,13 @@ public async Task CreateAsync(TodoListCreateDto dto) public async Task EditAsync(string id, TodoListCreateDto dto) { // Get the model - var todoList = await _repository.GetByIdAsync(id); + var todoList = await _todoListsRepository.GetByIdAsync(id); // Return false if the model doesn't exist if (todoList == null) return false; // Update the model dto.Adapt(todoList); - await _repository.UpdateAsync(todoList); + await _todoListsRepository.UpdateAsync(todoList); return true; } @@ -85,12 +135,12 @@ public async Task EditAsync(string id, TodoListCreateDto dto) public async Task DeleteAsync(string id) { // Get the model - var todoList = await _repository.GetByIdAsync(id); + var todoList = await _todoListsRepository.GetByIdAsync(id); // Return false if the model doesn't exist if (todoList == null) return false; // Delete the model - await _repository.DeleteAsync(todoList); + await _todoListsRepository.DeleteAsync(todoList); return true; } diff --git a/AdvancedTodoList.Infrastructure/Specifications/GetByIdSpecification.cs b/AdvancedTodoList.Infrastructure/Specifications/GetByIdSpecification.cs index c6bd4ff..1a0c20c 100644 --- a/AdvancedTodoList.Infrastructure/Specifications/GetByIdSpecification.cs +++ b/AdvancedTodoList.Infrastructure/Specifications/GetByIdSpecification.cs @@ -17,7 +17,7 @@ public class GetByIdSpecification(TKey id) : ISpecification /// The unique identifier to filter by. /// - protected TKey Id { get; } = id; + public TKey Id { get; } = id; /// /// Gets the criteria expression that defines the filtering conditions. diff --git a/AdvancedTodoList.Infrastructure/Specifications/TodoItemAggregateSpecification.cs b/AdvancedTodoList.Infrastructure/Specifications/TodoItemAggregateSpecification.cs new file mode 100644 index 0000000..a8b80f3 --- /dev/null +++ b/AdvancedTodoList.Infrastructure/Specifications/TodoItemAggregateSpecification.cs @@ -0,0 +1,19 @@ +using AdvancedTodoList.Core.Models.TodoLists; +using System.Linq.Expressions; + +namespace AdvancedTodoList.Infrastructure.Specifications; + +/// +/// Represents a specification used for obtaining a to-do list item aggregate. +/// +/// The unique identifier of the to-do list item to obtain. +public class TodoItemAggregateSpecification(int id) : GetByIdSpecification(id) +{ + /// + /// Gets the list of include expressions specifying an owner. + /// + public override List>> Includes => + [ + x => x.Owner + ]; +} diff --git a/AdvancedTodoList.Infrastructure/Specifications/TodoListAggregateSpecification.cs b/AdvancedTodoList.Infrastructure/Specifications/TodoListAggregateSpecification.cs new file mode 100644 index 0000000..5aab3e5 --- /dev/null +++ b/AdvancedTodoList.Infrastructure/Specifications/TodoListAggregateSpecification.cs @@ -0,0 +1,19 @@ +using AdvancedTodoList.Core.Models.TodoLists; +using System.Linq.Expressions; + +namespace AdvancedTodoList.Infrastructure.Specifications; + +/// +/// Represents a specification used for obtaining a to-do list aggregate. +/// +/// The unique identifier of the to-do list to obtain. +public class TodoListAggregateSpecification(string id) : GetByIdSpecification(id) +{ + /// + /// Gets the list of include expressions specifying an owner. + /// + public override List>> Includes => + [ + x => x.Owner + ]; +} diff --git a/AdvancedTodoList.IntegrationTests/Endpoints/TodoItemsEndpointsTests.cs b/AdvancedTodoList.IntegrationTests/Endpoints/TodoItemsEndpointsTests.cs index b91afbc..5c3129f 100644 --- a/AdvancedTodoList.IntegrationTests/Endpoints/TodoItemsEndpointsTests.cs +++ b/AdvancedTodoList.IntegrationTests/Endpoints/TodoItemsEndpointsTests.cs @@ -101,7 +101,8 @@ public async Task GetTodoItemById_ElementExists_ReturnsElement() // Arrange string testListId = "TestId"; int testItemId = 777; - TodoItemGetByIdDto testDto = new(testItemId, testListId, "Test todo item", "...", null, TodoItemState.Active); + TodoItemGetByIdDto testDto = new(testItemId, testListId, "Test todo item", + "...", null, TodoItemState.Active, new("Id", "User")); WebApplicationFactory.TodoItemsService .GetByIdAsync(testListId, testItemId) @@ -158,8 +159,9 @@ public async Task PostTodoItem_ValidCall_Succeeds() string listId = "ListId"; TodoItemCreateDto dto = new("Item", "...", DateTime.MaxValue); WebApplicationFactory.TodoItemsService - .CreateAsync(listId, dto) - .Returns(new TodoItemGetByIdDto(500, "TodoListId", "", "", DateTime.UtcNow, TodoItemState.Active)); + .CreateAsync(listId, dto, TestUserId) + .Returns(new TodoItemGetByIdDto(500, "TodoListId", "", "", DateTime.UtcNow, + TodoItemState.Active, new("Id", "User"))); using HttpClient client = CreateAuthorizedHttpClient(); // Act: send the request @@ -170,7 +172,7 @@ public async Task PostTodoItem_ValidCall_Succeeds() // Assert that create method was called await WebApplicationFactory.TodoItemsService .Received() - .CreateAsync(listId, dto); + .CreateAsync(listId, dto, TestUserId); } [Test] @@ -180,7 +182,7 @@ public async Task PostTodoItem_TodoListDoesNotExist_Returns404() string listId = "ListId"; TodoItemCreateDto dto = new("Item", "...", DateTime.MaxValue); WebApplicationFactory.TodoItemsService - .CreateAsync(listId, dto) + .CreateAsync(listId, dto, TestUserId) .ReturnsNull(); using HttpClient client = CreateAuthorizedHttpClient(); diff --git a/AdvancedTodoList.IntegrationTests/Endpoints/TodoListsEndpointsTests.cs b/AdvancedTodoList.IntegrationTests/Endpoints/TodoListsEndpointsTests.cs index 2775dd7..e4c5917 100644 --- a/AdvancedTodoList.IntegrationTests/Endpoints/TodoListsEndpointsTests.cs +++ b/AdvancedTodoList.IntegrationTests/Endpoints/TodoListsEndpointsTests.cs @@ -13,7 +13,7 @@ public async Task GetTodoListById_ElementExists_ReturnsElement() { // Arrange string testId = "TestId"; - TodoListGetByIdDto testDto = new(testId, "Test todo list", ""); + TodoListGetByIdDto testDto = new(testId, "Test todo list", "", new("Id", "User")); WebApplicationFactory.TodoListsService .GetByIdAsync(testId) .Returns(testDto); @@ -66,8 +66,8 @@ public async Task PostTodoList_ValidCall_Succeeds() { // Arrange WebApplicationFactory.TodoListsService - .CreateAsync(Arg.Any()) - .Returns(new TodoListGetByIdDto("Id", "", "")); + .CreateAsync(Arg.Any(), Arg.Any()) + .Returns(new TodoListGetByIdDto("Id", "", "", new("Id", "User"))); TodoListCreateDto dto = new("Test", string.Empty); using HttpClient client = CreateAuthorizedHttpClient(); @@ -79,7 +79,7 @@ public async Task PostTodoList_ValidCall_Succeeds() // Assert that create method was called await WebApplicationFactory.TodoListsService .Received() - .CreateAsync(dto); + .CreateAsync(dto, TestUserId); } [Test] diff --git a/AdvancedTodoList.IntegrationTests/Factories/BusinessLogicWebApplicationFactory.cs b/AdvancedTodoList.IntegrationTests/Factories/BusinessLogicWebApplicationFactory.cs index a7fd930..e6779a4 100644 --- a/AdvancedTodoList.IntegrationTests/Factories/BusinessLogicWebApplicationFactory.cs +++ b/AdvancedTodoList.IntegrationTests/Factories/BusinessLogicWebApplicationFactory.cs @@ -22,7 +22,9 @@ public class BusinessLogicWebApplicationFactory : WebApplicationFactory public ITodoListDependantEntitiesService TodoRolesHelperService { get; set; } = null!; public ITodoListDependantEntitiesService TodoMembersHelperService { get; set; } = null!; public IUserRefreshTokensRepository RefreshTokensRepository { get; private set; } = null!; + public IRepository TodoListRolesRepository { get; private set; } = null!; public ITodoListMembersRepository TodoListMembersRepository { get; private set; } = null!; + public IUnitOfWork UnitOfWork { get; private set; } = null!; protected override void ConfigureWebHost(IWebHostBuilder builder) { @@ -35,8 +37,14 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) TodoRolesHelperService = Substitute.For>(); TodoMembersHelperService = Substitute.For>(); RefreshTokensRepository = Substitute.For(); + TodoListRolesRepository = Substitute.For>(); TodoListMembersRepository = Substitute.For(); + UnitOfWork = Substitute.For(); + UnitOfWork.BeginTransactionAsync().Returns(Task.CompletedTask); + UnitOfWork.CommitAsync().Returns(Task.CompletedTask); + UnitOfWork.RollbackAsync().Returns(Task.CompletedTask); + builder.ConfigureTestServices(services => { services.AddScoped(_ => EntityExistenceChecker); @@ -47,7 +55,9 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) services.AddScoped(_ => TodoRolesHelperService); services.AddScoped(_ => TodoMembersHelperService); services.AddScoped(_ => RefreshTokensRepository); + services.AddScoped(_ => TodoListRolesRepository); services.AddScoped(_ => TodoListMembersRepository); + services.AddScoped(_ => UnitOfWork); }); } } diff --git a/AdvancedTodoList.IntegrationTests/Repositories/TodoItemsRepositoryTests.cs b/AdvancedTodoList.IntegrationTests/Repositories/TodoItemsRepositoryTests.cs index ed6d7c2..25736ec 100644 --- a/AdvancedTodoList.IntegrationTests/Repositories/TodoItemsRepositoryTests.cs +++ b/AdvancedTodoList.IntegrationTests/Repositories/TodoItemsRepositoryTests.cs @@ -1,4 +1,6 @@ -using AdvancedTodoList.Core.Models.TodoLists; +using AdvancedTodoList.Core.Dtos; +using AdvancedTodoList.Core.Models.TodoLists; +using AdvancedTodoList.Infrastructure.Specifications; using AdvancedTodoList.IntegrationTests.Utils; namespace AdvancedTodoList.IntegrationTests.Repositories; @@ -38,4 +40,28 @@ protected override async Task CreateTestEntityAsync() await DbContext.SaveChangesAsync(); return TestModels.CreateTestTodoItem(todoList.Id); } + + [Test] + public async Task GetAggregateAsync_TodoListAggregateSpecification_IncludesOwner() + { + // Arrange + var owner = TestModels.CreateTestUser(); + DbContext.Add(owner); + await DbContext.SaveChangesAsync(); + var todoList = TestModels.CreateTestTodoList(); + DbContext.Add(todoList); + await DbContext.SaveChangesAsync(); + var todoItem = TestModels.CreateTestTodoItem(todoList.Id, owner.Id); + DbContext.Add(todoItem); + await DbContext.SaveChangesAsync(); + TodoItemAggregateSpecification specification = new(todoItem.Id); + + // Act + var aggregate = await Repository.GetAggregateAsync(specification); + + // Assert + Assert.That(aggregate, Is.Not.Null); + Assert.That(aggregate.Owner, Is.Not.Null); + Assert.That(aggregate.Owner.Id, Is.EqualTo(owner.Id)); + } } diff --git a/AdvancedTodoList.IntegrationTests/Repositories/TodoListRepositoryTests.cs b/AdvancedTodoList.IntegrationTests/Repositories/TodoListRepositoryTests.cs index a667f2e..1ddbbcd 100644 --- a/AdvancedTodoList.IntegrationTests/Repositories/TodoListRepositoryTests.cs +++ b/AdvancedTodoList.IntegrationTests/Repositories/TodoListRepositoryTests.cs @@ -1,4 +1,6 @@ -using AdvancedTodoList.Core.Models.TodoLists; +using AdvancedTodoList.Core.Dtos; +using AdvancedTodoList.Core.Models.TodoLists; +using AdvancedTodoList.Infrastructure.Specifications; using AdvancedTodoList.IntegrationTests.Utils; namespace AdvancedTodoList.IntegrationTests.Repositories; @@ -30,4 +32,25 @@ protected override Task CreateTestEntityAsync() { return Task.FromResult(TestModels.CreateTestTodoList()); } + + [Test] + public async Task GetAggregateAsync_TodoListAggregateSpecification_IncludesOwner() + { + // Arrange + var owner = TestModels.CreateTestUser(); + DbContext.Add(owner); + await DbContext.SaveChangesAsync(); + var todoList = TestModels.CreateTestTodoList(owner.Id); + DbContext.Add(todoList); + await DbContext.SaveChangesAsync(); + TodoListAggregateSpecification specification = new(todoList.Id); + + // Act + var aggregate = await Repository.GetAggregateAsync(specification); + + // Assert + Assert.That(aggregate, Is.Not.Null); + Assert.That(aggregate.Owner, Is.Not.Null); + Assert.That(aggregate.Owner.Id, Is.EqualTo(owner.Id)); + } } diff --git a/AdvancedTodoList.IntegrationTests/Services/TodoItemsServiceTests.cs b/AdvancedTodoList.IntegrationTests/Services/TodoItemsServiceTests.cs index 7bbcb3a..82ca82d 100644 --- a/AdvancedTodoList.IntegrationTests/Services/TodoItemsServiceTests.cs +++ b/AdvancedTodoList.IntegrationTests/Services/TodoItemsServiceTests.cs @@ -18,6 +18,64 @@ public void SetUp() _service = ServiceScope.ServiceProvider.GetService()!; } + [Test] + public async Task GetByIdAsync_EntityExists_AppliesTodoListAggregateSpecification() + { + // Arrange + int todoItemId = 123; + string todoListId = "TodoListId"; + TodoItemGetByIdDto dto = new(todoItemId, todoListId, "Name", "Description", null, + TodoItemState.Active, new("User", "Name")); + WebApplicationFactory.TodoItemsRepository + .GetAggregateAsync(Arg.Any>()) + .Returns(dto); + + // Act + var result = await _service.GetByIdAsync(todoListId, todoItemId); + + // Assert that valid specification was applied + await WebApplicationFactory.TodoItemsRepository + .Received() + .GetAggregateAsync( + Arg.Is(x => x.Id == todoItemId)); + } + + [Test] + public async Task GetByIdAsync_InvalidTodoListId_ReturnsNull() + { + // Arrange + int todoItemId = 123; + string todoListId = "TodoListId"; + TodoItemGetByIdDto dto = new(todoItemId, todoListId, "Name", "Description", null, + TodoItemState.Active, new("User", "Name")); + WebApplicationFactory.TodoItemsRepository + .GetAggregateAsync(Arg.Any>()) + .Returns(dto); + + // Act + var result = await _service.GetByIdAsync("Wrong list ID", todoItemId); + + // Assert + Assert.That(result, Is.Null); + } + + [Test] + public async Task GetByIdAsync_EntityDoesNotExist_ReturnsNull() + { + // Arrange + string todoListId = "ID"; + int itemId = 123; + WebApplicationFactory.TodoItemsRepository + .GetAggregateAsync(Arg.Any>()) + .ReturnsNull(); + + // Act + var result = await _service.GetByIdAsync(todoListId, itemId); + + // Assert + Assert.That(result, Is.Null); + } + [Test] public async Task GetItemsOfListAsync_ListExists_AppliesTodoItemsSpecification() { @@ -40,5 +98,48 @@ await WebApplicationFactory.TodoItemsHelperService Arg.Any()); } + [Test] + public async Task CreateAsync_TodoListExists_AddsEntityToDb() + { + // Arrange: initialize a DTO + string todoListId = "ID"; + string callerId = "CallerId"; + TodoItemCreateDto dto = new("Name", "Description", DateTime.UtcNow); + WebApplicationFactory.EntityExistenceChecker + .ExistsAsync(todoListId) + .Returns(true); + WebApplicationFactory.TodoItemsRepository + .AddAsync(Arg.Any()) + .Returns(Task.FromResult); + + // Act: call the method + var result = await _service.CreateAsync(todoListId, dto, callerId); + + // Assert + Assert.That(result, Is.Not.Null); + await WebApplicationFactory.TodoItemsRepository + .Received() + .AddAsync(Arg.Is(x => + x.Name == dto.Name && x.Description == dto.Description && x.OwnerId == callerId)); + } + + [Test] + public async Task CreateAsync_TodoListDoesNotExist_ReturnsNull() + { + // Arrange: initialize a DTO + string todoListId = "ID"; + string callerId = "CallerId"; + TodoItemCreateDto dto = new("Name", "Description", DateTime.UtcNow); + WebApplicationFactory.EntityExistenceChecker + .ExistsAsync(todoListId) + .Returns(false); + + // Act: call the method + var result = await _service.CreateAsync(todoListId, dto, callerId); + + // Assert + Assert.That(result, Is.Null); + } + // Tests for other methods are useless, because they are just wrappers. } diff --git a/AdvancedTodoList.IntegrationTests/Services/TodoListsServiceTests.cs b/AdvancedTodoList.IntegrationTests/Services/TodoListsServiceTests.cs index eced50d..e0b9d56 100644 --- a/AdvancedTodoList.IntegrationTests/Services/TodoListsServiceTests.cs +++ b/AdvancedTodoList.IntegrationTests/Services/TodoListsServiceTests.cs @@ -1,9 +1,12 @@ using AdvancedTodoList.Core.Dtos; using AdvancedTodoList.Core.Models.TodoLists; +using AdvancedTodoList.Core.Models.TodoLists.Members; using AdvancedTodoList.Core.Services; +using AdvancedTodoList.Core.Specifications; +using AdvancedTodoList.Infrastructure.Specifications; using AdvancedTodoList.IntegrationTests.Fixtures; using AdvancedTodoList.IntegrationTests.Utils; -using Mapster; +using NSubstitute.ExceptionExtensions; namespace AdvancedTodoList.IntegrationTests.Services; @@ -19,20 +22,23 @@ public void SetUp() } [Test] - public async Task GetByIdAsync_EntityExists_ReturnsCorrectEntity() + public async Task GetByIdAsync_EntityExists_AppliesTodoListAggregateSpecification() { // Arrange - TodoList todoList = TestModels.CreateTestTodoList(); + string todoListId = "TodoListId"; + TodoListGetByIdDto dto = new("Id", "name", "", new("Id", "User")); WebApplicationFactory.TodoListsRepository - .GetByIdAsync(todoList.Id) - .Returns(todoList); + .GetAggregateAsync(Arg.Any>()) + .Returns(dto); // Act - var result = await _service.GetByIdAsync(todoList.Id); + var result = await _service.GetByIdAsync(todoListId); - // Assert that returned DTO matches - var expectedResult = todoList.Adapt(); - Assert.That(result, Is.EqualTo(expectedResult)); + // Assert that valid specification was applied + await WebApplicationFactory.TodoListsRepository + .Received() + .GetAggregateAsync( + Arg.Is(x => x.Id == todoListId)); } [Test] @@ -41,7 +47,7 @@ public async Task GetByIdAsync_EntityDoesNotExist_ReturnsNull() // Arrange string todoListId = "ID"; WebApplicationFactory.TodoListsRepository - .GetByIdAsync(todoListId) + .GetAggregateAsync(Arg.Any>()) .ReturnsNull(); // Act @@ -54,19 +60,65 @@ public async Task GetByIdAsync_EntityDoesNotExist_ReturnsNull() [Test] public async Task CreateAsync_AddsEntityToDb() { - // Arrange: initialize a DTO + // Arrange + string callerId = "CallerId"; + string listId = "ListId"; + int ownerRoleId = 777; TodoListCreateDto dto = new("Test entity", "..."); WebApplicationFactory.TodoListsRepository .AddAsync(Arg.Any()) + .Returns(Task.FromResult) + .AndDoes(x => ((TodoList)x[0]).Id = listId); + WebApplicationFactory.TodoListRolesRepository + .AddAsync(Arg.Any()) + .Returns(Task.FromResult) + .AndDoes(x => ((TodoListRole)x[0]).Id = ownerRoleId); + WebApplicationFactory.TodoListMembersRepository + .AddAsync(Arg.Any()) .Returns(Task.FromResult); - // Act: call the method - var result = await _service.CreateAsync(dto); + // Act + var result = await _service.CreateAsync(dto, callerId); - // Assert that method was called + // Assert that list was created await WebApplicationFactory.TodoListsRepository .Received() - .AddAsync(Arg.Is(x => x.Name == dto.Name && x.Description == dto.Description)); + .AddAsync(Arg.Is(x => x.Name == dto.Name && + x.Description == dto.Description && x.OwnerId == callerId)); + // Assert that "Owner" role was created + await WebApplicationFactory.TodoListRolesRepository + .Received() + .AddAsync(Arg.Is(x => x.Priority == 0 && + x.TodoListId == listId && x.Permissions == RolePermissions.All)); + // Assert that the caller was assigned to the "Owner" role + await WebApplicationFactory.TodoListMembersRepository + .Received() + .AddAsync(Arg.Is(x => x.TodoListId == listId && + x.UserId == callerId && x.RoleId == ownerRoleId)); + // Assert that changes were commited + await WebApplicationFactory.UnitOfWork + .Received() + .CommitAsync(); + } + + [Test] + public async Task CreateAsync_OnException_RethrowsAndRollbacksChanges() + { + // Arrange + string callerId = "CallerId"; + TodoListCreateDto dto = new("Test entity", "..."); + WebApplicationFactory.TodoListsRepository + .AddAsync(Arg.Any()) + .Throws(); + + // Act/Assert + Assert.ThrowsAsync(() => _service.CreateAsync(dto, callerId)); + + // Assert + await WebApplicationFactory.UnitOfWork + .Received() + .RollbackAsync(); + } [Test] diff --git a/AdvancedTodoList.IntegrationTests/Utils/TestModels.cs b/AdvancedTodoList.IntegrationTests/Utils/TestModels.cs index e20810d..efeb7f0 100644 --- a/AdvancedTodoList.IntegrationTests/Utils/TestModels.cs +++ b/AdvancedTodoList.IntegrationTests/Utils/TestModels.cs @@ -15,18 +15,20 @@ public static class TestModels /// /// Creates and returns a valid model of a to-do list which can be added to the DB. /// - public static TodoList CreateTestTodoList() => new() + public static TodoList CreateTestTodoList(string? ownerId = null) => new() { Name = "Name", - Description = "Description" + Description = "Description", + OwnerId = ownerId }; /// /// Creates and returns a valid model of a to-do list item which can be added to the DB. /// - public static TodoItem CreateTestTodoItem(string todoListId) => new() + public static TodoItem CreateTestTodoItem(string todoListId, string? ownerId = null) => new() { Name = "Name", Description = "Description", + OwnerId = ownerId, State = TodoItemState.Completed, DeadlineDate = DateTime.UtcNow.AddDays(365), TodoListId = todoListId diff --git a/AdvancedTodoList/Controllers/TodoItemsController.cs b/AdvancedTodoList/Controllers/TodoItemsController.cs index 358603f..667f292 100644 --- a/AdvancedTodoList/Controllers/TodoItemsController.cs +++ b/AdvancedTodoList/Controllers/TodoItemsController.cs @@ -1,6 +1,7 @@ using AdvancedTodoList.Core.Dtos; using AdvancedTodoList.Core.Pagination; using AdvancedTodoList.Core.Services; +using AdvancedTodoList.Extensions; using Mapster; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -73,7 +74,7 @@ public async Task GetTodoItemByIdAsync( public async Task PostTodoItemAsync( [FromRoute] string listId, [FromBody] TodoItemCreateDto dto) { - var item = await _rolesService.CreateAsync(listId, dto); + var item = await _rolesService.CreateAsync(listId, dto, User.GetUserId()!); if (item == null) return NotFound(); var routeValues = new { listId, itemId = item.Id }; diff --git a/AdvancedTodoList/Controllers/TodoListsController.cs b/AdvancedTodoList/Controllers/TodoListsController.cs index 3b7d025..240424f 100644 --- a/AdvancedTodoList/Controllers/TodoListsController.cs +++ b/AdvancedTodoList/Controllers/TodoListsController.cs @@ -1,5 +1,6 @@ using AdvancedTodoList.Core.Dtos; using AdvancedTodoList.Core.Services; +using AdvancedTodoList.Extensions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -8,9 +9,12 @@ namespace AdvancedTodoList.Controllers; [Authorize] [ApiController] [Route("api/todo")] -public class TodoListsController(ITodoListsService todoListsService) : ControllerBase +public class TodoListsController( + ITodoListsService todoListsService, + ILogger logger) : ControllerBase { private readonly ITodoListsService _todoListsService = todoListsService; + private readonly ILogger _logger = logger; /// /// Gets a to-do list by its ID. @@ -41,7 +45,7 @@ public async Task GetTodoListByIdAsync([FromRoute] string listId) [ProducesResponseType(StatusCodes.Status401Unauthorized)] public async Task PostTodoListAsync([FromBody] TodoListCreateDto dto) { - var list = await _todoListsService.CreateAsync(dto); + var list = await _todoListsService.CreateAsync(dto, User.GetUserId()!); var routeValues = new { listId = list.Id }; return CreatedAtRoute(nameof(GetTodoListByIdAsync), routeValues, list); }