From 494c12df499a79fadae1073f1428c18f4026eb7b Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Mon, 30 Jun 2025 18:59:05 +0700 Subject: [PATCH 001/101] MySQL migration --- .../Controllers/FriendsControllerTests.cs | 2 - .../Validators/UsernameValidatorTests.cs | 2 +- .../AdminStuff/InviteUserController.cs | 2 +- .../ConfigurationProgramExtensions.cs | 22 +- Govor.API/Govor.API.csproj | 1 + Govor.API/appsettings.json | 3 +- .../Validators/UsernameValidator.cs | 2 +- Govor.Data/Govor.Data.csproj | 1 + .../20250624133431_InitialCreate.Designer.cs | 383 ----------------- .../20250624133431_InitialCreate.cs | 277 ------------- ...250624141242_inviteAddIsActive.Designer.cs | 386 ------------------ .../20250624141242_inviteAddIsActive.cs | 29 -- .../Migrations/20250627101322_friendships.cs | 104 ----- ... 20250630111826_InitialCreate.Designer.cs} | 137 +++---- .../20250630111826_InitialCreate.cs | 372 +++++++++++++++++ .../Migrations/GovorDbContextModelSnapshot.cs | 133 +++--- 16 files changed, 528 insertions(+), 1328 deletions(-) delete mode 100644 Govor.Data/Migrations/20250624133431_InitialCreate.Designer.cs delete mode 100644 Govor.Data/Migrations/20250624133431_InitialCreate.cs delete mode 100644 Govor.Data/Migrations/20250624141242_inviteAddIsActive.Designer.cs delete mode 100644 Govor.Data/Migrations/20250624141242_inviteAddIsActive.cs delete mode 100644 Govor.Data/Migrations/20250627101322_friendships.cs rename Govor.Data/Migrations/{20250627101322_friendships.Designer.cs => 20250630111826_InitialCreate.Designer.cs} (77%) create mode 100644 Govor.Data/Migrations/20250630111826_InitialCreate.cs diff --git a/Govor.API.Tests/IntegrationTests/Controllers/FriendsControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/FriendsControllerTests.cs index 124d67d..97ecc7b 100644 --- a/Govor.API.Tests/IntegrationTests/Controllers/FriendsControllerTests.cs +++ b/Govor.API.Tests/IntegrationTests/Controllers/FriendsControllerTests.cs @@ -104,8 +104,6 @@ public class FriendsControllerTests var objectResult = result as ObjectResult; Assert.That(objectResult.StatusCode, Is.EqualTo(500)); } - - [TearDown] public void TearDown() diff --git a/Govor.API.Tests/UnitTests/Services/Validators/UsernameValidatorTests.cs b/Govor.API.Tests/UnitTests/Services/Validators/UsernameValidatorTests.cs index 510baba..991319f 100644 --- a/Govor.API.Tests/UnitTests/Services/Validators/UsernameValidatorTests.cs +++ b/Govor.API.Tests/UnitTests/Services/Validators/UsernameValidatorTests.cs @@ -1,5 +1,5 @@ -using Govor.Application.Validators; // или другой namespace using Govor.Application.Exceptions.AuthService; +using Govor.Application.Infrastructure.Validators; namespace Govor.API.Tests.UnitTests.Services.Validators; diff --git a/Govor.API/Controllers/AdminStuff/InviteUserController.cs b/Govor.API/Controllers/AdminStuff/InviteUserController.cs index 0e2724b..9f6e68a 100644 --- a/Govor.API/Controllers/AdminStuff/InviteUserController.cs +++ b/Govor.API/Controllers/AdminStuff/InviteUserController.cs @@ -9,7 +9,7 @@ namespace Govor.API.Controllers.AdminStuff; [Route("api/[controller]")] [ApiController] -[Authorize(Roles = "Admin")] +//[Authorize(Roles = "Admin")] public class InviteUserController : Controller { private readonly IInvitesRepository _repository; diff --git a/Govor.API/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Extensions/ConfigurationProgramExtensions.cs index 76876ef..15b3eeb 100644 --- a/Govor.API/Extensions/ConfigurationProgramExtensions.cs +++ b/Govor.API/Extensions/ConfigurationProgramExtensions.cs @@ -1,12 +1,12 @@ using Govor.API.Services.AdminsStuff.Interfaces; using Govor.API.Services.Authentication.Interfaces; using Govor.Application.Infrastructure.Extensions; +using Govor.Application.Infrastructure.Validators; using Govor.Application.Interfaces; using Govor.Application.Interfaces.AdminsStuff; using Govor.Application.Interfaces.Authentication; using Govor.Application.Interfaces.Infrastructure.Extensions; using Govor.Application.Services; -using Govor.Application.Validators; using Govor.Core.Infrastructure.Extensions; using Govor.Core.Infrastructure.Validators; using Govor.Core.Models; @@ -67,11 +67,19 @@ public static class ConfigurationProgramExtensions public static void AddGovorDbContext(this IServiceCollection services, IConfiguration configuration) { - services.AddDbContext( - options => - { - options.UseNpgsql(configuration.GetConnectionString(nameof(GovorDbContext))); - } - ); + var useMySql = configuration.GetValue("UseMySql"); // Получаем значение из конфигурации + + if (useMySql) + { + services.AddDbContext(options => + options.UseMySql(configuration.GetConnectionString(nameof(GovorDbContext)), + new MySqlServerVersion(new Version(8, 0, 21)))); + } + else + { + services.AddDbContext(options => + options.UseNpgsql(configuration.GetConnectionString(nameof(GovorDbContext)))); + } } + } \ No newline at end of file diff --git a/Govor.API/Govor.API.csproj b/Govor.API/Govor.API.csproj index d855102..320becf 100644 --- a/Govor.API/Govor.API.csproj +++ b/Govor.API/Govor.API.csproj @@ -18,6 +18,7 @@ + diff --git a/Govor.API/appsettings.json b/Govor.API/appsettings.json index be2ac70..132a5a0 100644 --- a/Govor.API/appsettings.json +++ b/Govor.API/appsettings.json @@ -6,8 +6,9 @@ } }, "ConnectionStrings": { - "GovorDbContext": "Host=localhost;Port=5432;Database=DbGovor;Username=postgres;Password=stalcker;" + "GovorDbContext": "Server=147.45.255.215;Port=3306;Database=artemy_DB;User=artemy;Password=LoxHuy))228Goy;" }, + "UseMySql": true, "AllowedHosts": "*", "JwtOption": { "SecretKeу": "MY VERY SECRET KEY asdasdpafjhasofafpajsfj", diff --git a/Govor.Application/Infrastructure/Validators/UsernameValidator.cs b/Govor.Application/Infrastructure/Validators/UsernameValidator.cs index 3d0420d..2a41e37 100644 --- a/Govor.Application/Infrastructure/Validators/UsernameValidator.cs +++ b/Govor.Application/Infrastructure/Validators/UsernameValidator.cs @@ -4,7 +4,7 @@ using Govor.Application.Exceptions.AuthService; using Govor.Application.Interfaces.Authentication; using Govor.Core.Infrastructure.Validators; -namespace Govor.Application.Validators; +namespace Govor.Application.Infrastructure.Validators; public class UsernameValidator : IUsernameValidator { diff --git a/Govor.Data/Govor.Data.csproj b/Govor.Data/Govor.Data.csproj index 29dc018..0b6f472 100644 --- a/Govor.Data/Govor.Data.csproj +++ b/Govor.Data/Govor.Data.csproj @@ -9,6 +9,7 @@ + diff --git a/Govor.Data/Migrations/20250624133431_InitialCreate.Designer.cs b/Govor.Data/Migrations/20250624133431_InitialCreate.Designer.cs deleted file mode 100644 index a799edb..0000000 --- a/Govor.Data/Migrations/20250624133431_InitialCreate.Designer.cs +++ /dev/null @@ -1,383 +0,0 @@ -// -using System; -using System.Collections.Generic; -using Govor.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Govor.Data.Migrations -{ - [DbContext(typeof(GovorDbContext))] - [Migration("20250624133431_InitialCreate")] - partial class InitialCreate - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "9.0.6") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Govor.Core.Models.Admin", b => - { - b.Property("UserId") - .HasColumnType("uuid"); - - b.HasKey("UserId"); - - b.ToTable("Admins"); - }); - - modelBuilder.Entity("Govor.Core.Models.ChatGroup", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.PrimitiveCollection>("Admins") - .IsRequired() - .HasColumnType("uuid[]"); - - b.PrimitiveCollection>("InviteCode") - .IsRequired() - .HasColumnType("text[]"); - - b.Property("IsChannel") - .HasColumnType("boolean"); - - b.Property("IsPrivate") - .HasColumnType("boolean"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("ChatGroups"); - }); - - modelBuilder.Entity("Govor.Core.Models.GroupAdmins", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("GroupId") - .HasColumnType("uuid"); - - b.Property("UserId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.ToTable("GroupAdmins"); - }); - - modelBuilder.Entity("Govor.Core.Models.GroupMembership", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("GroupId") - .HasColumnType("uuid"); - - b.Property("IsBanned") - .HasColumnType("boolean"); - - b.Property("UserId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.ToTable("GroupMemberships"); - }); - - modelBuilder.Entity("Govor.Core.Models.Invitation", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Code") - .IsRequired() - .HasColumnType("text"); - - b.Property("DateCreated") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b.Property("EndDate") - .HasColumnType("timestamp with time zone"); - - b.Property("IsAdmin") - .HasColumnType("boolean"); - - b.Property("MaxParticipants") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.ToTable("Invitations"); - }); - - modelBuilder.Entity("Govor.Core.Models.MediaAttachments", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("EncryptedKey") - .HasMaxLength(512) - .HasColumnType("character varying(512)"); - - b.Property("FilePath") - .IsRequired() - .HasColumnType("text"); - - b.Property("MessageId") - .HasColumnType("uuid"); - - b.Property("MimeType") - .IsRequired() - .HasColumnType("text"); - - b.Property("Type") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("MessageId"); - - b.ToTable("MediaAttachments"); - }); - - modelBuilder.Entity("Govor.Core.Models.Message", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("EditedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("EncryptedContent") - .IsRequired() - .HasColumnType("text"); - - b.Property("IsEdited") - .HasColumnType("boolean"); - - b.Property("RecipientId") - .HasColumnType("uuid"); - - b.Property("RecipientType") - .HasColumnType("integer"); - - b.Property("ReplyToMessageId") - .HasColumnType("uuid"); - - b.Property("SenderId") - .HasColumnType("uuid"); - - b.Property("SentAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("ReplyToMessageId"); - - b.ToTable("Messages"); - }); - - modelBuilder.Entity("Govor.Core.Models.MessageReaction", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("MessageId") - .HasColumnType("uuid"); - - b.Property("ReactedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ReactionCode") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.Property("UserId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.HasIndex("MessageId", "UserId") - .IsUnique(); - - b.ToTable("MessageReactions"); - }); - - modelBuilder.Entity("Govor.Core.Models.MessageView", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("MessageId") - .HasColumnType("uuid"); - - b.Property("UserId") - .HasColumnType("uuid"); - - b.Property("ViewedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("MessageId", "UserId") - .IsUnique(); - - b.ToTable("MessageViews"); - }); - - modelBuilder.Entity("Govor.Core.Models.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedOn") - .HasColumnType("date"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b.Property("IconId") - .HasColumnType("uuid"); - - b.Property("InviteId") - .HasColumnType("uuid"); - - b.Property("PasswordHash") - .IsRequired() - .HasColumnType("text"); - - b.Property("Username") - .IsRequired() - .HasColumnType("text"); - - b.Property("WasOnline") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("InviteId"); - - b.ToTable("Users"); - }); - - modelBuilder.Entity("Govor.Core.Models.Admin", b => - { - b.HasOne("Govor.Core.Models.User", "User") - .WithOne() - .HasForeignKey("Govor.Core.Models.Admin", "UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Govor.Core.Models.MediaAttachments", b => - { - b.HasOne("Govor.Core.Models.Message", "Message") - .WithMany("MediaAttachments") - .HasForeignKey("MessageId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Message"); - }); - - modelBuilder.Entity("Govor.Core.Models.Message", b => - { - b.HasOne("Govor.Core.Models.Message", "ReplyToMessage") - .WithMany() - .HasForeignKey("ReplyToMessageId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("ReplyToMessage"); - }); - - modelBuilder.Entity("Govor.Core.Models.MessageReaction", b => - { - b.HasOne("Govor.Core.Models.Message", "Message") - .WithMany("Reactions") - .HasForeignKey("MessageId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Govor.Core.Models.User", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Message"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Govor.Core.Models.MessageView", b => - { - b.HasOne("Govor.Core.Models.Message", null) - .WithMany("MessageViews") - .HasForeignKey("MessageId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Govor.Core.Models.User", b => - { - b.HasOne("Govor.Core.Models.Invitation", "Invite") - .WithMany("Users") - .HasForeignKey("InviteId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Invite"); - }); - - modelBuilder.Entity("Govor.Core.Models.Invitation", b => - { - b.Navigation("Users"); - }); - - modelBuilder.Entity("Govor.Core.Models.Message", b => - { - b.Navigation("MediaAttachments"); - - b.Navigation("MessageViews"); - - b.Navigation("Reactions"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Govor.Data/Migrations/20250624133431_InitialCreate.cs b/Govor.Data/Migrations/20250624133431_InitialCreate.cs deleted file mode 100644 index 2b61e65..0000000 --- a/Govor.Data/Migrations/20250624133431_InitialCreate.cs +++ /dev/null @@ -1,277 +0,0 @@ -using System; -using System.Collections.Generic; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Govor.Data.Migrations -{ - /// - public partial class InitialCreate : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "ChatGroups", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - Name = table.Column(type: "text", nullable: false), - InviteCode = table.Column>(type: "text[]", nullable: false), - IsChannel = table.Column(type: "boolean", nullable: false), - IsPrivate = table.Column(type: "boolean", nullable: false), - Admins = table.Column>(type: "uuid[]", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_ChatGroups", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "GroupAdmins", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - GroupId = table.Column(type: "uuid", nullable: false), - UserId = table.Column(type: "uuid", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_GroupAdmins", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "GroupMemberships", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - GroupId = table.Column(type: "uuid", nullable: false), - UserId = table.Column(type: "uuid", nullable: false), - IsBanned = table.Column(type: "boolean", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_GroupMemberships", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "Invitations", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - IsAdmin = table.Column(type: "boolean", nullable: false), - Code = table.Column(type: "text", nullable: false), - Description = table.Column(type: "text", nullable: false), - DateCreated = table.Column(type: "timestamp with time zone", nullable: false), - EndDate = table.Column(type: "timestamp with time zone", nullable: false), - MaxParticipants = table.Column(type: "integer", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Invitations", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "Messages", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - SenderId = table.Column(type: "uuid", nullable: false), - RecipientId = table.Column(type: "uuid", nullable: false), - RecipientType = table.Column(type: "integer", nullable: false), - EncryptedContent = table.Column(type: "text", nullable: false), - SentAt = table.Column(type: "timestamp with time zone", nullable: false), - IsEdited = table.Column(type: "boolean", nullable: false), - EditedAt = table.Column(type: "timestamp with time zone", nullable: true), - ReplyToMessageId = table.Column(type: "uuid", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Messages", x => x.Id); - table.ForeignKey( - name: "FK_Messages_Messages_ReplyToMessageId", - column: x => x.ReplyToMessageId, - principalTable: "Messages", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "Users", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - Username = table.Column(type: "text", nullable: false), - Description = table.Column(type: "text", nullable: false), - PasswordHash = table.Column(type: "text", nullable: false), - IconId = table.Column(type: "uuid", nullable: false), - CreatedOn = table.Column(type: "date", nullable: false), - WasOnline = table.Column(type: "timestamp with time zone", nullable: false), - InviteId = table.Column(type: "uuid", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Users", x => x.Id); - table.ForeignKey( - name: "FK_Users_Invitations_InviteId", - column: x => x.InviteId, - principalTable: "Invitations", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "MediaAttachments", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - MessageId = table.Column(type: "uuid", nullable: false), - Type = table.Column(type: "text", nullable: false), - FilePath = table.Column(type: "text", nullable: false), - MimeType = table.Column(type: "text", nullable: false), - EncryptedKey = table.Column(type: "character varying(512)", maxLength: 512, nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_MediaAttachments", x => x.Id); - table.ForeignKey( - name: "FK_MediaAttachments_Messages_MessageId", - column: x => x.MessageId, - principalTable: "Messages", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "MessageViews", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - MessageId = table.Column(type: "uuid", nullable: false), - UserId = table.Column(type: "uuid", nullable: false), - ViewedAt = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_MessageViews", x => x.Id); - table.ForeignKey( - name: "FK_MessageViews_Messages_MessageId", - column: x => x.MessageId, - principalTable: "Messages", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "Admins", - columns: table => new - { - UserId = table.Column(type: "uuid", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Admins", x => x.UserId); - table.ForeignKey( - name: "FK_Admins_Users_UserId", - column: x => x.UserId, - principalTable: "Users", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "MessageReactions", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - MessageId = table.Column(type: "uuid", nullable: false), - UserId = table.Column(type: "uuid", nullable: false), - ReactionCode = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), - ReactedAt = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_MessageReactions", x => x.Id); - table.ForeignKey( - name: "FK_MessageReactions_Messages_MessageId", - column: x => x.MessageId, - principalTable: "Messages", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_MessageReactions_Users_UserId", - column: x => x.UserId, - principalTable: "Users", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_MediaAttachments_MessageId", - table: "MediaAttachments", - column: "MessageId"); - - migrationBuilder.CreateIndex( - name: "IX_MessageReactions_MessageId_UserId", - table: "MessageReactions", - columns: new[] { "MessageId", "UserId" }, - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_MessageReactions_UserId", - table: "MessageReactions", - column: "UserId"); - - migrationBuilder.CreateIndex( - name: "IX_Messages_ReplyToMessageId", - table: "Messages", - column: "ReplyToMessageId"); - - migrationBuilder.CreateIndex( - name: "IX_MessageViews_MessageId_UserId", - table: "MessageViews", - columns: new[] { "MessageId", "UserId" }, - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_Users_InviteId", - table: "Users", - column: "InviteId"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Admins"); - - migrationBuilder.DropTable( - name: "ChatGroups"); - - migrationBuilder.DropTable( - name: "GroupAdmins"); - - migrationBuilder.DropTable( - name: "GroupMemberships"); - - migrationBuilder.DropTable( - name: "MediaAttachments"); - - migrationBuilder.DropTable( - name: "MessageReactions"); - - migrationBuilder.DropTable( - name: "MessageViews"); - - migrationBuilder.DropTable( - name: "Users"); - - migrationBuilder.DropTable( - name: "Messages"); - - migrationBuilder.DropTable( - name: "Invitations"); - } - } -} diff --git a/Govor.Data/Migrations/20250624141242_inviteAddIsActive.Designer.cs b/Govor.Data/Migrations/20250624141242_inviteAddIsActive.Designer.cs deleted file mode 100644 index bd2e361..0000000 --- a/Govor.Data/Migrations/20250624141242_inviteAddIsActive.Designer.cs +++ /dev/null @@ -1,386 +0,0 @@ -// -using System; -using System.Collections.Generic; -using Govor.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Govor.Data.Migrations -{ - [DbContext(typeof(GovorDbContext))] - [Migration("20250624141242_inviteAddIsActive")] - partial class inviteAddIsActive - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "9.0.6") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Govor.Core.Models.Admin", b => - { - b.Property("UserId") - .HasColumnType("uuid"); - - b.HasKey("UserId"); - - b.ToTable("Admins"); - }); - - modelBuilder.Entity("Govor.Core.Models.ChatGroup", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.PrimitiveCollection>("Admins") - .IsRequired() - .HasColumnType("uuid[]"); - - b.PrimitiveCollection>("InviteCode") - .IsRequired() - .HasColumnType("text[]"); - - b.Property("IsChannel") - .HasColumnType("boolean"); - - b.Property("IsPrivate") - .HasColumnType("boolean"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("ChatGroups"); - }); - - modelBuilder.Entity("Govor.Core.Models.GroupAdmins", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("GroupId") - .HasColumnType("uuid"); - - b.Property("UserId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.ToTable("GroupAdmins"); - }); - - modelBuilder.Entity("Govor.Core.Models.GroupMembership", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("GroupId") - .HasColumnType("uuid"); - - b.Property("IsBanned") - .HasColumnType("boolean"); - - b.Property("UserId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.ToTable("GroupMemberships"); - }); - - modelBuilder.Entity("Govor.Core.Models.Invitation", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Code") - .IsRequired() - .HasColumnType("text"); - - b.Property("DateCreated") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b.Property("EndDate") - .HasColumnType("timestamp with time zone"); - - b.Property("IsActive") - .HasColumnType("boolean"); - - b.Property("IsAdmin") - .HasColumnType("boolean"); - - b.Property("MaxParticipants") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.ToTable("Invitations"); - }); - - modelBuilder.Entity("Govor.Core.Models.MediaAttachments", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("EncryptedKey") - .HasMaxLength(512) - .HasColumnType("character varying(512)"); - - b.Property("FilePath") - .IsRequired() - .HasColumnType("text"); - - b.Property("MessageId") - .HasColumnType("uuid"); - - b.Property("MimeType") - .IsRequired() - .HasColumnType("text"); - - b.Property("Type") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("MessageId"); - - b.ToTable("MediaAttachments"); - }); - - modelBuilder.Entity("Govor.Core.Models.Message", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("EditedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("EncryptedContent") - .IsRequired() - .HasColumnType("text"); - - b.Property("IsEdited") - .HasColumnType("boolean"); - - b.Property("RecipientId") - .HasColumnType("uuid"); - - b.Property("RecipientType") - .HasColumnType("integer"); - - b.Property("ReplyToMessageId") - .HasColumnType("uuid"); - - b.Property("SenderId") - .HasColumnType("uuid"); - - b.Property("SentAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("ReplyToMessageId"); - - b.ToTable("Messages"); - }); - - modelBuilder.Entity("Govor.Core.Models.MessageReaction", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("MessageId") - .HasColumnType("uuid"); - - b.Property("ReactedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ReactionCode") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.Property("UserId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.HasIndex("MessageId", "UserId") - .IsUnique(); - - b.ToTable("MessageReactions"); - }); - - modelBuilder.Entity("Govor.Core.Models.MessageView", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("MessageId") - .HasColumnType("uuid"); - - b.Property("UserId") - .HasColumnType("uuid"); - - b.Property("ViewedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("MessageId", "UserId") - .IsUnique(); - - b.ToTable("MessageViews"); - }); - - modelBuilder.Entity("Govor.Core.Models.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedOn") - .HasColumnType("date"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b.Property("IconId") - .HasColumnType("uuid"); - - b.Property("InviteId") - .HasColumnType("uuid"); - - b.Property("PasswordHash") - .IsRequired() - .HasColumnType("text"); - - b.Property("Username") - .IsRequired() - .HasColumnType("text"); - - b.Property("WasOnline") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("InviteId"); - - b.ToTable("Users"); - }); - - modelBuilder.Entity("Govor.Core.Models.Admin", b => - { - b.HasOne("Govor.Core.Models.User", "User") - .WithOne() - .HasForeignKey("Govor.Core.Models.Admin", "UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Govor.Core.Models.MediaAttachments", b => - { - b.HasOne("Govor.Core.Models.Message", "Message") - .WithMany("MediaAttachments") - .HasForeignKey("MessageId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Message"); - }); - - modelBuilder.Entity("Govor.Core.Models.Message", b => - { - b.HasOne("Govor.Core.Models.Message", "ReplyToMessage") - .WithMany() - .HasForeignKey("ReplyToMessageId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("ReplyToMessage"); - }); - - modelBuilder.Entity("Govor.Core.Models.MessageReaction", b => - { - b.HasOne("Govor.Core.Models.Message", "Message") - .WithMany("Reactions") - .HasForeignKey("MessageId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Govor.Core.Models.User", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Message"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Govor.Core.Models.MessageView", b => - { - b.HasOne("Govor.Core.Models.Message", null) - .WithMany("MessageViews") - .HasForeignKey("MessageId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Govor.Core.Models.User", b => - { - b.HasOne("Govor.Core.Models.Invitation", "Invite") - .WithMany("Users") - .HasForeignKey("InviteId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Invite"); - }); - - modelBuilder.Entity("Govor.Core.Models.Invitation", b => - { - b.Navigation("Users"); - }); - - modelBuilder.Entity("Govor.Core.Models.Message", b => - { - b.Navigation("MediaAttachments"); - - b.Navigation("MessageViews"); - - b.Navigation("Reactions"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Govor.Data/Migrations/20250624141242_inviteAddIsActive.cs b/Govor.Data/Migrations/20250624141242_inviteAddIsActive.cs deleted file mode 100644 index c43c141..0000000 --- a/Govor.Data/Migrations/20250624141242_inviteAddIsActive.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Govor.Data.Migrations -{ - /// - public partial class inviteAddIsActive : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "IsActive", - table: "Invitations", - type: "boolean", - nullable: false, - defaultValue: false); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "IsActive", - table: "Invitations"); - } - } -} diff --git a/Govor.Data/Migrations/20250627101322_friendships.cs b/Govor.Data/Migrations/20250627101322_friendships.cs deleted file mode 100644 index 37cfa43..0000000 --- a/Govor.Data/Migrations/20250627101322_friendships.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Govor.Data.Migrations -{ - /// - public partial class friendships : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "PrivateChatId", - table: "Messages", - type: "uuid", - nullable: true); - - migrationBuilder.CreateTable( - name: "Friendships", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - RequesterId = table.Column(type: "uuid", nullable: false), - AddresseeId = table.Column(type: "uuid", nullable: false), - Status = table.Column(type: "integer", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Friendships", x => x.Id); - table.ForeignKey( - name: "FK_Friendships_Users_AddresseeId", - column: x => x.AddresseeId, - principalTable: "Users", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_Friendships_Users_RequesterId", - column: x => x.RequesterId, - principalTable: "Users", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "PrivateChats", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - UserAId = table.Column(type: "uuid", nullable: false), - UserBId = table.Column(type: "uuid", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_PrivateChats", x => x.Id); - }); - - migrationBuilder.CreateIndex( - name: "IX_Messages_PrivateChatId", - table: "Messages", - column: "PrivateChatId"); - - migrationBuilder.CreateIndex( - name: "IX_Friendships_AddresseeId", - table: "Friendships", - column: "AddresseeId"); - - migrationBuilder.CreateIndex( - name: "IX_Friendships_RequesterId", - table: "Friendships", - column: "RequesterId"); - - migrationBuilder.AddForeignKey( - name: "FK_Messages_PrivateChats_PrivateChatId", - table: "Messages", - column: "PrivateChatId", - principalTable: "PrivateChats", - principalColumn: "Id"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropForeignKey( - name: "FK_Messages_PrivateChats_PrivateChatId", - table: "Messages"); - - migrationBuilder.DropTable( - name: "Friendships"); - - migrationBuilder.DropTable( - name: "PrivateChats"); - - migrationBuilder.DropIndex( - name: "IX_Messages_PrivateChatId", - table: "Messages"); - - migrationBuilder.DropColumn( - name: "PrivateChatId", - table: "Messages"); - } - } -} diff --git a/Govor.Data/Migrations/20250627101322_friendships.Designer.cs b/Govor.Data/Migrations/20250630111826_InitialCreate.Designer.cs similarity index 77% rename from Govor.Data/Migrations/20250627101322_friendships.Designer.cs rename to Govor.Data/Migrations/20250630111826_InitialCreate.Designer.cs index e3350fc..fc022ce 100644 --- a/Govor.Data/Migrations/20250627101322_friendships.Designer.cs +++ b/Govor.Data/Migrations/20250630111826_InitialCreate.Designer.cs @@ -1,20 +1,19 @@ // using System; -using System.Collections.Generic; using Govor.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable namespace Govor.Data.Migrations { [DbContext(typeof(GovorDbContext))] - [Migration("20250627101322_friendships")] - partial class friendships + [Migration("20250630111826_InitialCreate")] + partial class InitialCreate { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -22,14 +21,14 @@ namespace Govor.Data.Migrations #pragma warning disable 612, 618 modelBuilder .HasAnnotation("ProductVersion", "9.0.6") - .HasAnnotation("Relational:MaxIdentifierLength", 63); + .HasAnnotation("Relational:MaxIdentifierLength", 64); - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); modelBuilder.Entity("Govor.Core.Models.Admin", b => { b.Property("UserId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.HasKey("UserId"); @@ -40,25 +39,25 @@ namespace Govor.Data.Migrations { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uuid"); + .HasColumnType("char(36)"); - b.PrimitiveCollection>("Admins") + b.PrimitiveCollection("Admins") .IsRequired() - .HasColumnType("uuid[]"); + .HasColumnType("longtext"); - b.PrimitiveCollection>("InviteCode") + b.PrimitiveCollection("InviteCode") .IsRequired() - .HasColumnType("text[]"); + .HasColumnType("longtext"); b.Property("IsChannel") - .HasColumnType("boolean"); + .HasColumnType("tinyint(1)"); b.Property("IsPrivate") - .HasColumnType("boolean"); + .HasColumnType("tinyint(1)"); b.Property("Name") .IsRequired() - .HasColumnType("text"); + .HasColumnType("longtext"); b.HasKey("Id"); @@ -69,16 +68,16 @@ namespace Govor.Data.Migrations { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("AddresseeId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("RequesterId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("Status") - .HasColumnType("integer"); + .HasColumnType("int"); b.HasKey("Id"); @@ -93,13 +92,13 @@ namespace Govor.Data.Migrations { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("GroupId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("UserId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.HasKey("Id"); @@ -110,16 +109,16 @@ namespace Govor.Data.Migrations { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("GroupId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("IsBanned") - .HasColumnType("boolean"); + .HasColumnType("tinyint(1)"); b.Property("UserId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.HasKey("Id"); @@ -130,30 +129,30 @@ namespace Govor.Data.Migrations { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("Code") .IsRequired() - .HasColumnType("text"); + .HasColumnType("longtext"); b.Property("DateCreated") - .HasColumnType("timestamp with time zone"); + .HasColumnType("datetime(6)"); b.Property("Description") .IsRequired() - .HasColumnType("text"); + .HasColumnType("longtext"); b.Property("EndDate") - .HasColumnType("timestamp with time zone"); + .HasColumnType("datetime(6)"); b.Property("IsActive") - .HasColumnType("boolean"); + .HasColumnType("tinyint(1)"); b.Property("IsAdmin") - .HasColumnType("boolean"); + .HasColumnType("tinyint(1)"); b.Property("MaxParticipants") - .HasColumnType("integer"); + .HasColumnType("int"); b.HasKey("Id"); @@ -164,26 +163,26 @@ namespace Govor.Data.Migrations { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("EncryptedKey") .HasMaxLength(512) - .HasColumnType("character varying(512)"); + .HasColumnType("varchar(512)"); b.Property("FilePath") .IsRequired() - .HasColumnType("text"); + .HasColumnType("longtext"); b.Property("MessageId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("MimeType") .IsRequired() - .HasColumnType("text"); + .HasColumnType("longtext"); b.Property("Type") .IsRequired() - .HasColumnType("text"); + .HasColumnType("longtext"); b.HasKey("Id"); @@ -196,35 +195,35 @@ namespace Govor.Data.Migrations { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("EditedAt") - .HasColumnType("timestamp with time zone"); + .HasColumnType("datetime(6)"); b.Property("EncryptedContent") .IsRequired() - .HasColumnType("text"); + .HasColumnType("longtext"); b.Property("IsEdited") - .HasColumnType("boolean"); + .HasColumnType("tinyint(1)"); b.Property("PrivateChatId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("RecipientId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("RecipientType") - .HasColumnType("integer"); + .HasColumnType("int"); b.Property("ReplyToMessageId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("SenderId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("SentAt") - .HasColumnType("timestamp with time zone"); + .HasColumnType("datetime(6)"); b.HasKey("Id"); @@ -239,21 +238,21 @@ namespace Govor.Data.Migrations { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("MessageId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("ReactedAt") - .HasColumnType("timestamp with time zone"); + .HasColumnType("datetime(6)"); b.Property("ReactionCode") .IsRequired() .HasMaxLength(64) - .HasColumnType("character varying(64)"); + .HasColumnType("varchar(64)"); b.Property("UserId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.HasKey("Id"); @@ -269,16 +268,16 @@ namespace Govor.Data.Migrations { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("MessageId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("UserId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("ViewedAt") - .HasColumnType("timestamp with time zone"); + .HasColumnType("datetime(6)"); b.HasKey("Id"); @@ -292,13 +291,13 @@ namespace Govor.Data.Migrations { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("UserAId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("UserBId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.HasKey("Id"); @@ -309,31 +308,31 @@ namespace Govor.Data.Migrations { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("CreatedOn") .HasColumnType("date"); b.Property("Description") .IsRequired() - .HasColumnType("text"); + .HasColumnType("longtext"); b.Property("IconId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("InviteId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("PasswordHash") .IsRequired() - .HasColumnType("text"); + .HasColumnType("longtext"); b.Property("Username") .IsRequired() - .HasColumnType("text"); + .HasColumnType("longtext"); b.Property("WasOnline") - .HasColumnType("timestamp with time zone"); + .HasColumnType("datetime(6)"); b.HasKey("Id"); diff --git a/Govor.Data/Migrations/20250630111826_InitialCreate.cs b/Govor.Data/Migrations/20250630111826_InitialCreate.cs new file mode 100644 index 0000000..9fe9cbd --- /dev/null +++ b/Govor.Data/Migrations/20250630111826_InitialCreate.cs @@ -0,0 +1,372 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Govor.Data.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "ChatGroups", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + Name = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + InviteCode = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + IsChannel = table.Column(type: "tinyint(1)", nullable: false), + IsPrivate = table.Column(type: "tinyint(1)", nullable: false), + Admins = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_ChatGroups", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "GroupAdmins", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + GroupId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + UserId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci") + }, + constraints: table => + { + table.PrimaryKey("PK_GroupAdmins", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "GroupMemberships", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + GroupId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + UserId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + IsBanned = table.Column(type: "tinyint(1)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_GroupMemberships", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "Invitations", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + IsAdmin = table.Column(type: "tinyint(1)", nullable: false), + IsActive = table.Column(type: "tinyint(1)", nullable: false), + Code = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Description = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + DateCreated = table.Column(type: "datetime(6)", nullable: false), + EndDate = table.Column(type: "datetime(6)", nullable: false), + MaxParticipants = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Invitations", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "PrivateChats", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + UserAId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + UserBId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci") + }, + constraints: table => + { + table.PrimaryKey("PK_PrivateChats", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + Username = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Description = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + PasswordHash = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + IconId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + CreatedOn = table.Column(type: "date", nullable: false), + WasOnline = table.Column(type: "datetime(6)", nullable: false), + InviteId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci") + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + table.ForeignKey( + name: "FK_Users_Invitations_InviteId", + column: x => x.InviteId, + principalTable: "Invitations", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "Messages", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + SenderId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + RecipientId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + RecipientType = table.Column(type: "int", nullable: false), + EncryptedContent = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + SentAt = table.Column(type: "datetime(6)", nullable: false), + IsEdited = table.Column(type: "tinyint(1)", nullable: false), + EditedAt = table.Column(type: "datetime(6)", nullable: true), + ReplyToMessageId = table.Column(type: "char(36)", nullable: true, collation: "ascii_general_ci"), + PrivateChatId = table.Column(type: "char(36)", nullable: true, collation: "ascii_general_ci") + }, + constraints: table => + { + table.PrimaryKey("PK_Messages", x => x.Id); + table.ForeignKey( + name: "FK_Messages_Messages_ReplyToMessageId", + column: x => x.ReplyToMessageId, + principalTable: "Messages", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_Messages_PrivateChats_PrivateChatId", + column: x => x.PrivateChatId, + principalTable: "PrivateChats", + principalColumn: "Id"); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "Admins", + columns: table => new + { + UserId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci") + }, + constraints: table => + { + table.PrimaryKey("PK_Admins", x => x.UserId); + table.ForeignKey( + name: "FK_Admins_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "Friendships", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + RequesterId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + AddresseeId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + Status = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Friendships", x => x.Id); + table.ForeignKey( + name: "FK_Friendships_Users_AddresseeId", + column: x => x.AddresseeId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_Friendships_Users_RequesterId", + column: x => x.RequesterId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "MediaAttachments", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + MessageId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + Type = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + FilePath = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + MimeType = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + EncryptedKey = table.Column(type: "varchar(512)", maxLength: 512, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_MediaAttachments", x => x.Id); + table.ForeignKey( + name: "FK_MediaAttachments_Messages_MessageId", + column: x => x.MessageId, + principalTable: "Messages", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "MessageReactions", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + MessageId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + UserId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + ReactionCode = table.Column(type: "varchar(64)", maxLength: 64, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + ReactedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MessageReactions", x => x.Id); + table.ForeignKey( + name: "FK_MessageReactions_Messages_MessageId", + column: x => x.MessageId, + principalTable: "Messages", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_MessageReactions_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "MessageViews", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + MessageId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + UserId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + ViewedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MessageViews", x => x.Id); + table.ForeignKey( + name: "FK_MessageViews_Messages_MessageId", + column: x => x.MessageId, + principalTable: "Messages", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_Friendships_AddresseeId", + table: "Friendships", + column: "AddresseeId"); + + migrationBuilder.CreateIndex( + name: "IX_Friendships_RequesterId", + table: "Friendships", + column: "RequesterId"); + + migrationBuilder.CreateIndex( + name: "IX_MediaAttachments_MessageId", + table: "MediaAttachments", + column: "MessageId"); + + migrationBuilder.CreateIndex( + name: "IX_MessageReactions_MessageId_UserId", + table: "MessageReactions", + columns: new[] { "MessageId", "UserId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_MessageReactions_UserId", + table: "MessageReactions", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_Messages_PrivateChatId", + table: "Messages", + column: "PrivateChatId"); + + migrationBuilder.CreateIndex( + name: "IX_Messages_ReplyToMessageId", + table: "Messages", + column: "ReplyToMessageId"); + + migrationBuilder.CreateIndex( + name: "IX_MessageViews_MessageId_UserId", + table: "MessageViews", + columns: new[] { "MessageId", "UserId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Users_InviteId", + table: "Users", + column: "InviteId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Admins"); + + migrationBuilder.DropTable( + name: "ChatGroups"); + + migrationBuilder.DropTable( + name: "Friendships"); + + migrationBuilder.DropTable( + name: "GroupAdmins"); + + migrationBuilder.DropTable( + name: "GroupMemberships"); + + migrationBuilder.DropTable( + name: "MediaAttachments"); + + migrationBuilder.DropTable( + name: "MessageReactions"); + + migrationBuilder.DropTable( + name: "MessageViews"); + + migrationBuilder.DropTable( + name: "Users"); + + migrationBuilder.DropTable( + name: "Messages"); + + migrationBuilder.DropTable( + name: "Invitations"); + + migrationBuilder.DropTable( + name: "PrivateChats"); + } + } +} diff --git a/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs b/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs index 703662e..4b9f74d 100644 --- a/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs +++ b/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs @@ -1,11 +1,10 @@ // using System; -using System.Collections.Generic; using Govor.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable @@ -19,14 +18,14 @@ namespace Govor.Data.Migrations #pragma warning disable 612, 618 modelBuilder .HasAnnotation("ProductVersion", "9.0.6") - .HasAnnotation("Relational:MaxIdentifierLength", 63); + .HasAnnotation("Relational:MaxIdentifierLength", 64); - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); modelBuilder.Entity("Govor.Core.Models.Admin", b => { b.Property("UserId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.HasKey("UserId"); @@ -37,25 +36,25 @@ namespace Govor.Data.Migrations { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uuid"); + .HasColumnType("char(36)"); - b.PrimitiveCollection>("Admins") + b.PrimitiveCollection("Admins") .IsRequired() - .HasColumnType("uuid[]"); + .HasColumnType("longtext"); - b.PrimitiveCollection>("InviteCode") + b.PrimitiveCollection("InviteCode") .IsRequired() - .HasColumnType("text[]"); + .HasColumnType("longtext"); b.Property("IsChannel") - .HasColumnType("boolean"); + .HasColumnType("tinyint(1)"); b.Property("IsPrivate") - .HasColumnType("boolean"); + .HasColumnType("tinyint(1)"); b.Property("Name") .IsRequired() - .HasColumnType("text"); + .HasColumnType("longtext"); b.HasKey("Id"); @@ -66,16 +65,16 @@ namespace Govor.Data.Migrations { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("AddresseeId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("RequesterId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("Status") - .HasColumnType("integer"); + .HasColumnType("int"); b.HasKey("Id"); @@ -90,13 +89,13 @@ namespace Govor.Data.Migrations { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("GroupId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("UserId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.HasKey("Id"); @@ -107,16 +106,16 @@ namespace Govor.Data.Migrations { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("GroupId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("IsBanned") - .HasColumnType("boolean"); + .HasColumnType("tinyint(1)"); b.Property("UserId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.HasKey("Id"); @@ -127,30 +126,30 @@ namespace Govor.Data.Migrations { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("Code") .IsRequired() - .HasColumnType("text"); + .HasColumnType("longtext"); b.Property("DateCreated") - .HasColumnType("timestamp with time zone"); + .HasColumnType("datetime(6)"); b.Property("Description") .IsRequired() - .HasColumnType("text"); + .HasColumnType("longtext"); b.Property("EndDate") - .HasColumnType("timestamp with time zone"); + .HasColumnType("datetime(6)"); b.Property("IsActive") - .HasColumnType("boolean"); + .HasColumnType("tinyint(1)"); b.Property("IsAdmin") - .HasColumnType("boolean"); + .HasColumnType("tinyint(1)"); b.Property("MaxParticipants") - .HasColumnType("integer"); + .HasColumnType("int"); b.HasKey("Id"); @@ -161,26 +160,26 @@ namespace Govor.Data.Migrations { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("EncryptedKey") .HasMaxLength(512) - .HasColumnType("character varying(512)"); + .HasColumnType("varchar(512)"); b.Property("FilePath") .IsRequired() - .HasColumnType("text"); + .HasColumnType("longtext"); b.Property("MessageId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("MimeType") .IsRequired() - .HasColumnType("text"); + .HasColumnType("longtext"); b.Property("Type") .IsRequired() - .HasColumnType("text"); + .HasColumnType("longtext"); b.HasKey("Id"); @@ -193,35 +192,35 @@ namespace Govor.Data.Migrations { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("EditedAt") - .HasColumnType("timestamp with time zone"); + .HasColumnType("datetime(6)"); b.Property("EncryptedContent") .IsRequired() - .HasColumnType("text"); + .HasColumnType("longtext"); b.Property("IsEdited") - .HasColumnType("boolean"); + .HasColumnType("tinyint(1)"); b.Property("PrivateChatId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("RecipientId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("RecipientType") - .HasColumnType("integer"); + .HasColumnType("int"); b.Property("ReplyToMessageId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("SenderId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("SentAt") - .HasColumnType("timestamp with time zone"); + .HasColumnType("datetime(6)"); b.HasKey("Id"); @@ -236,21 +235,21 @@ namespace Govor.Data.Migrations { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("MessageId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("ReactedAt") - .HasColumnType("timestamp with time zone"); + .HasColumnType("datetime(6)"); b.Property("ReactionCode") .IsRequired() .HasMaxLength(64) - .HasColumnType("character varying(64)"); + .HasColumnType("varchar(64)"); b.Property("UserId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.HasKey("Id"); @@ -266,16 +265,16 @@ namespace Govor.Data.Migrations { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("MessageId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("UserId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("ViewedAt") - .HasColumnType("timestamp with time zone"); + .HasColumnType("datetime(6)"); b.HasKey("Id"); @@ -289,13 +288,13 @@ namespace Govor.Data.Migrations { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("UserAId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("UserBId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.HasKey("Id"); @@ -306,31 +305,31 @@ namespace Govor.Data.Migrations { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("CreatedOn") .HasColumnType("date"); b.Property("Description") .IsRequired() - .HasColumnType("text"); + .HasColumnType("longtext"); b.Property("IconId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("InviteId") - .HasColumnType("uuid"); + .HasColumnType("char(36)"); b.Property("PasswordHash") .IsRequired() - .HasColumnType("text"); + .HasColumnType("longtext"); b.Property("Username") .IsRequired() - .HasColumnType("text"); + .HasColumnType("longtext"); b.Property("WasOnline") - .HasColumnType("timestamp with time zone"); + .HasColumnType("datetime(6)"); b.HasKey("Id"); From ab426979d093f1d2f810c76b1cd9f2c91a0ae571 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Mon, 30 Jun 2025 19:04:35 +0700 Subject: [PATCH 002/101] Update AuthControllerTests.cs --- .../IntegrationTests/Controllers/AuthControllerTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Govor.API.Tests/IntegrationTests/Controllers/AuthControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/AuthControllerTests.cs index 82808d2..010dd68 100644 --- a/Govor.API.Tests/IntegrationTests/Controllers/AuthControllerTests.cs +++ b/Govor.API.Tests/IntegrationTests/Controllers/AuthControllerTests.cs @@ -86,8 +86,8 @@ public class AuthControllerTests var result = await _controller.Register(request); // Assert - Assert.That(result, Is.InstanceOf()); - var notFoundObjectResult = result as NotFoundObjectResult; + Assert.That(result, Is.InstanceOf()); + var notFoundObjectResult = result as BadRequestObjectResult; Assert.That(notFoundObjectResult.Value, Is.EqualTo("Invite link invalid.")); } From 8e245ea04421ef2b446cf9af685cf2fbcca76ed8 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Mon, 30 Jun 2025 19:27:29 +0700 Subject: [PATCH 003/101] Test for SendRequest action --- .../Controllers/FriendsControllerTests.cs | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/Govor.API.Tests/IntegrationTests/Controllers/FriendsControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/FriendsControllerTests.cs index 97ecc7b..a940339 100644 --- a/Govor.API.Tests/IntegrationTests/Controllers/FriendsControllerTests.cs +++ b/Govor.API.Tests/IntegrationTests/Controllers/FriendsControllerTests.cs @@ -105,6 +105,70 @@ public class FriendsControllerTests Assert.That(objectResult.StatusCode, Is.EqualTo(500)); } + // Test for SendRequest action + [Test] + public async Task SendRequest_ValidRequest_ReturnsOk() + { + var targetUserId = Guid.NewGuid(); + + var result = await _controller.SendRequest(targetUserId); + + Assert.That(result, Is.InstanceOf()); + var okResult = result as OkObjectResult; + Assert.That(okResult?.Value.ToString(), Does.Contain("Friend request sent")); + } + + [Test] + public async Task SendRequest_Throws_InvalidOperationException_ReturnsUnprocessableEntity() + { + var targetUserId = Guid.NewGuid(); + + _currentUserServiceMock.Setup(c => c.GetCurrentUserId()).Returns(targetUserId); + + _friendsServiceMock.Setup(f => f.SendFriendRequestAsync(targetUserId, targetUserId)) + .ThrowsAsync(new InvalidOperationException()); + + var result = await _controller.SendRequest(targetUserId); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task SendRequest_Throws_RequestAlreadySentException_ReturnsConflict() + { + var targetUserId = Guid.NewGuid(); + var currentUserId = Guid.NewGuid(); + + _currentUserServiceMock.Setup(c => c.GetCurrentUserId()).Returns(currentUserId); + + _friendsServiceMock.Setup(f => f.SendFriendRequestAsync(targetUserId, currentUserId)) + .ThrowsAsync(new RequestAlreadySentException(currentUserId, targetUserId)); + + var result = await _controller.SendRequest(targetUserId); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task SendRequest_StatusCode500_IfThrowsSomeException() + { + // Arrange + _friendsServiceMock.Setup(f => f.SendFriendRequestAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception(_fixture.Create())); + + // Act + var result = await _controller.Search(_fixture.Create()); + + // Assert + Assert.That(result, Is.InstanceOf()); + var objectResult = result as ObjectResult; + Assert.That(objectResult.StatusCode, Is.EqualTo(500)); + } + + // Tests for GetIncomingRequests action + + + [TearDown] public void TearDown() { From f855b911557c840d69d6cce6c24c9a399bd638c5 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Tue, 1 Jul 2025 11:43:11 +0700 Subject: [PATCH 004/101] Completed the development of tests for FriendsController --- .../Controllers/FriendsControllerTests.cs | 208 ++++++++++++++++++ .../AdminStuff/InviteUserController.cs | 2 +- Govor.API/Controllers/FriendsController.cs | 2 +- 3 files changed, 210 insertions(+), 2 deletions(-) diff --git a/Govor.API.Tests/IntegrationTests/Controllers/FriendsControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/FriendsControllerTests.cs index a940339..9b51bf0 100644 --- a/Govor.API.Tests/IntegrationTests/Controllers/FriendsControllerTests.cs +++ b/Govor.API.Tests/IntegrationTests/Controllers/FriendsControllerTests.cs @@ -166,8 +166,216 @@ public class FriendsControllerTests } // Tests for GetIncomingRequests action + [Test] + public async Task GetIncomingRequests_ValidRequest_ReturnsOkResult() + { + // Arrange + var currentId = _fixture.Create(); + var friendships = _fixture.CreateMany().ToList(); + + _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) + .Returns(currentId); + + _friendsServiceMock.Setup(f => f.GetIncomingRequestsAsync(currentId)) + .ReturnsAsync(friendships); + // Act + var result = await _controller.GetIncomingRequests(); + + // Assert + Assert.That(result, Is.InstanceOf()); + var okResult = result as OkObjectResult; + List value = okResult.Value as List; + Assert.That(value, Is.Not.Null); + Assert.That(value.Count, Is.EqualTo(friendships.Count)); + } + + [Test] + public async Task GetIncomingRequests_Throws_InvalidOperationException_ReturnsBadRequest() + { + // Arrange + var currentId = _fixture.Create(); + + _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) + .Returns(currentId); + + _friendsServiceMock.Setup(f => f.GetIncomingRequestsAsync(currentId)) + .ThrowsAsync(new InvalidOperationException()); + + // Act + var result = await _controller.GetIncomingRequests(); + + // Assert + Assert.That(result, Is.InstanceOf()); + var badRequestResult = result as BadRequestObjectResult; + dynamic value = badRequestResult.Value; + Assert.That(value, Is.EqualTo("Failed to get friend requests. User data missing.")); + } + + [Test] + public async Task GetIncomingRequest_StatusCode500_IfThrowsSomeException() + { + // Arrange + var currentId = _fixture.Create(); + + _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) + .Returns(currentId); + + _friendsServiceMock.Setup(f => f.GetIncomingRequestsAsync(currentId)) + .ThrowsAsync(new Exception()); + + // Act + var result = await _controller.GetIncomingRequests(); + + // Assert + Assert.That(result, Is.InstanceOf()); + var objectResult = result as ObjectResult; + Assert.That(objectResult.StatusCode, Is.EqualTo(500)); + } + // Tests for AcceptFriend action + + [Test] + public async Task AcceptFriend_ValidRequest_ReturnsOkResult() + { + // Arrange + var currentId = Guid.NewGuid(); + var targetUserId = Guid.NewGuid(); + + _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) + .Returns(currentId); + // Act + var result = await _controller.AcceptFriend(targetUserId); + + // Assert + Assert.That(result, Is.InstanceOf()); + } + [Test] + public async Task AcceptFriend_InvalidOperationException_ReturnsNotFound() + { + // Arrange + var currentId = Guid.NewGuid(); + var targetUserId = Guid.NewGuid(); + + _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) + .Returns(currentId); + + _friendsServiceMock.Setup(f => f.AcceptFriendRequestAsync(targetUserId, currentId)) + .ThrowsAsync(new InvalidOperationException()); + + // Act + var result = await _controller.AcceptFriend(targetUserId); + + // Assert + Assert.That(result, Is.InstanceOf()); + } + [Test] + public async Task AcceptFriend_UnauthorizedAccessException_ReturnsForbid() + { + // Arrange + var currentId = Guid.NewGuid(); + var targetUserId = Guid.NewGuid(); + + _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) + .Returns(currentId); + + _friendsServiceMock.Setup(f => f.AcceptFriendRequestAsync(targetUserId, currentId)) + .ThrowsAsync(new UnauthorizedAccessException()); + + // Act + var result = await _controller.AcceptFriend(targetUserId); + + // Assert + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task AcceptFriend_Exception_ReturnsStatusCode500() + { + // Arrange + var currentId = Guid.NewGuid(); + var targetUserId = Guid.NewGuid(); + + _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) + .Returns(currentId); + + _friendsServiceMock.Setup(f => f.AcceptFriendRequestAsync(targetUserId, currentId)) + .ThrowsAsync(new Exception()); + + // Act + var result = await _controller.AcceptFriend(targetUserId); + + // Assert + Assert.That(result, Is.InstanceOf()); + var objectResult = result as ObjectResult; + Assert.That(objectResult.StatusCode, Is.EqualTo(500)); + } + + // Tests for GetFriends action + [Test] + public async Task GetFriends_ValidRequest_ReturnsOkResult() + { + // Arrange + var currentId = _fixture.Create(); + var users = _fixture.CreateMany().ToList(); + + _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) + .Returns(currentId); + + _friendsServiceMock.Setup(f => f.GetFriendsAsync(currentId)) + .ReturnsAsync(users); + // Act + var result = await _controller.GetFriends(); + + // Assert + Assert.That(result, Is.InstanceOf()); + var okResult = result as OkObjectResult; + List value = okResult.Value as List; + Assert.That(value, Is.Not.Null); + Assert.That(value.Count, Is.EqualTo(users.Count)); + } + + [Test] + public async Task GetFriends_InvalidOperationException_ReturnsBadRequest() + { + // Arrange + var currentId = _fixture.Create(); + var users = _fixture.CreateMany().ToList(); + + _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) + .Returns(currentId); + + _friendsServiceMock.Setup(f => f.GetFriendsAsync(currentId)) + .ThrowsAsync(new InvalidOperationException()); + + // Act + var result = await _controller.GetFriends(); + + // Assert + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task GetFriends_Exception_ReturnsStatusCode500() + { + // Arrange + var currentId = _fixture.Create(); + var users = _fixture.CreateMany().ToList(); + + _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) + .Returns(currentId); + + _friendsServiceMock.Setup(f => f.GetFriendsAsync(currentId)) + .ThrowsAsync(new Exception()); + + // Act + var result = await _controller.GetFriends(); + + // Assert + Assert.That(result, Is.InstanceOf()); + var objectResult = result as ObjectResult; + Assert.That(objectResult.StatusCode, Is.EqualTo(500)); + } [TearDown] public void TearDown() diff --git a/Govor.API/Controllers/AdminStuff/InviteUserController.cs b/Govor.API/Controllers/AdminStuff/InviteUserController.cs index 9f6e68a..0e2724b 100644 --- a/Govor.API/Controllers/AdminStuff/InviteUserController.cs +++ b/Govor.API/Controllers/AdminStuff/InviteUserController.cs @@ -9,7 +9,7 @@ namespace Govor.API.Controllers.AdminStuff; [Route("api/[controller]")] [ApiController] -//[Authorize(Roles = "Admin")] +[Authorize(Roles = "Admin")] public class InviteUserController : Controller { private readonly IInvitesRepository _repository; diff --git a/Govor.API/Controllers/FriendsController.cs b/Govor.API/Controllers/FriendsController.cs index 7000c2a..32c48f5 100644 --- a/Govor.API/Controllers/FriendsController.cs +++ b/Govor.API/Controllers/FriendsController.cs @@ -88,7 +88,7 @@ public class FriendsController : Controller catch (InvalidOperationException ex) { _logger.LogError(ex, ex.Message); - return BadRequest(new { error = "Failed to get friend requests. User data missing." }); + return BadRequest("Failed to get friend requests. User data missing."); } catch (Exception ex) { From 5883f3e34098fbaff8cdfa836f6c7f75417816fc Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Tue, 1 Jul 2025 13:25:55 +0700 Subject: [PATCH 005/101] hot fix --- .../Controllers/AuthControllerTests.cs | 8 +++--- .../Controllers/FriendsControllerTests.cs | 1 - .../Authentication/JwtServiceTests.cs | 2 +- .../Controllers/AdminStuff/UsersController.cs | 18 ++++++++++++- Govor.API/Controllers/AuthController.cs | 12 +++++---- Govor.API/Controllers/FriendsController.cs | 2 +- Govor.API/Govor.API.csproj | 3 +-- Govor.API/Program.cs | 7 +++-- .../Authentication/IInvitesService.cs | 4 +-- Govor.Application/Services/InvitesService.cs | 27 ++++++++++++------- Govor.Application/Services/JwtService.cs | 2 +- .../Responses/Admins/UserResponse.cs | 14 ++++++++++ 12 files changed, 70 insertions(+), 30 deletions(-) create mode 100644 Govor.Contracts/Responses/Admins/UserResponse.cs diff --git a/Govor.API.Tests/IntegrationTests/Controllers/AuthControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/AuthControllerTests.cs index 010dd68..1866a5f 100644 --- a/Govor.API.Tests/IntegrationTests/Controllers/AuthControllerTests.cs +++ b/Govor.API.Tests/IntegrationTests/Controllers/AuthControllerTests.cs @@ -48,7 +48,7 @@ public class AuthControllerTests var invitation = _fixture.Create(); var token = _fixture.Create(); - _invitesServiceMock.Setup(s => s.Validate(request.InviteLink)).Returns(invitation); + _invitesServiceMock.Setup(s => s.ValidateAsync(request.InviteLink)).ReturnsAsync(invitation); _accountServiceMock.Setup(s => s.RegistrationAsync(request.Name, request.Password, invitation)).ReturnsAsync(token); // Act @@ -80,7 +80,7 @@ public class AuthControllerTests { // Arrange var request = _fixture.Create(); - _invitesServiceMock.Setup(s => s.Validate(request.InviteLink)).Throws(new InviteLinkInvalidException(request.InviteLink)); + _invitesServiceMock.Setup(s => s.ValidateAsync(request.InviteLink)).ThrowsAsync(new InviteLinkInvalidException(request.InviteLink)); // Act var result = await _controller.Register(request); @@ -97,7 +97,7 @@ public class AuthControllerTests // Arrange var request = _fixture.Create(); var invitation = _fixture.Create(); - _invitesServiceMock.Setup(s => s.Validate(request.InviteLink)).Returns(invitation); + _invitesServiceMock.Setup(s => s.ValidateAsync(request.InviteLink)).ReturnsAsync(invitation); _accountServiceMock.Setup(s => s.RegistrationAsync(request.Name, request.Password, invitation)) .ThrowsAsync(new UserAlreadyExistException(request.Name)); @@ -116,7 +116,7 @@ public class AuthControllerTests // Arrange var request = _fixture.Create(); var invitation = _fixture.Create(); - _invitesServiceMock.Setup(s => s.Validate(request.InviteLink)).Returns(invitation); + _invitesServiceMock.Setup(s => s.ValidateAsync(request.InviteLink)).ReturnsAsync(invitation); _accountServiceMock.Setup(s => s.RegistrationAsync(request.Name, request.Password, invitation)) .ThrowsAsync(new System.Exception("Generic error")); diff --git a/Govor.API.Tests/IntegrationTests/Controllers/FriendsControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/FriendsControllerTests.cs index 9b51bf0..34d0791 100644 --- a/Govor.API.Tests/IntegrationTests/Controllers/FriendsControllerTests.cs +++ b/Govor.API.Tests/IntegrationTests/Controllers/FriendsControllerTests.cs @@ -233,7 +233,6 @@ public class FriendsControllerTests } // Tests for AcceptFriend action - [Test] public async Task AcceptFriend_ValidRequest_ReturnsOkResult() { diff --git a/Govor.API.Tests/UnitTests/Services/Authentication/JwtServiceTests.cs b/Govor.API.Tests/UnitTests/Services/Authentication/JwtServiceTests.cs index e33d71e..2eab678 100644 --- a/Govor.API.Tests/UnitTests/Services/Authentication/JwtServiceTests.cs +++ b/Govor.API.Tests/UnitTests/Services/Authentication/JwtServiceTests.cs @@ -45,7 +45,7 @@ public class JwtServiceTests // Arrange var user = _fixture.Create(); var expectedRole = "User"; - _invitesServiceMock.Setup(s => s.GetRole(user)).Returns(Task.FromResult(expectedRole)); + _invitesServiceMock.Setup(s => s.GetRoleAsync(user)).Returns(Task.FromResult(expectedRole)); // Act var tokenString = _jwtService.GenerateJwtToken(user); diff --git a/Govor.API/Controllers/AdminStuff/UsersController.cs b/Govor.API/Controllers/AdminStuff/UsersController.cs index 4a4ae04..fb2e97d 100644 --- a/Govor.API/Controllers/AdminStuff/UsersController.cs +++ b/Govor.API/Controllers/AdminStuff/UsersController.cs @@ -1,4 +1,6 @@ using Govor.API.Services.AdminsStuff.Interfaces; +using Govor.Contracts.Responses.Admins; +using Govor.Core.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -25,7 +27,7 @@ public class UsersController : Controller _logger.LogInformation("Getting all users by administrator"); var read = await _users.GetAllUsersAsync(); - return Ok(read); + return Ok(BuildUserDtos(read)); } [HttpGet("{id:guid}")] @@ -33,4 +35,18 @@ public class UsersController : Controller { return Ok(id); } + + private List BuildUserDtos(IEnumerable users) => users.Select(user => new UserResponse + { + Id = user.Id, + Username = user.Username, + Description = user.Description, + WasOnline = user.WasOnline, + IconId = user.IconId, + PasswordHash = user.PasswordHash, + InviteId = user.InviteId, + CreatedOn = user.CreatedOn, + IsAdmin = user.Invite?.IsAdmin ?? false, + }).ToList(); + } \ No newline at end of file diff --git a/Govor.API/Controllers/AuthController.cs b/Govor.API/Controllers/AuthController.cs index 34badb2..20d8287 100644 --- a/Govor.API/Controllers/AuthController.cs +++ b/Govor.API/Controllers/AuthController.cs @@ -3,11 +3,13 @@ using Govor.Application.Exceptions.AuthService; using Govor.Application.Exceptions.InvitesService; using Govor.Application.Interfaces.Authentication; using Govor.Contracts.Requests; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Govor.API.Controllers; [ApiController] +[AllowAnonymous] [Route("api/[controller]")] public class AuthController : Controller { @@ -23,7 +25,7 @@ public class AuthController : Controller } [HttpPost("register")]// api/auth/register - [RequireHttps] + //[RequireHttps] public async Task Register([FromBody] RegistrationRequest registrationRequest) { try @@ -33,11 +35,11 @@ public class AuthController : Controller return BadRequest(ModelState); } - var invite = _invitesService.Validate(registrationRequest.InviteLink); + var invite = await _invitesService.ValidateAsync(registrationRequest.InviteLink); var token = await _accountService.RegistrationAsync(registrationRequest.Name, registrationRequest.Password, invite); - _logger.LogInformation($"Register request for {registrationRequest.Name}"); + _logger.LogInformation($"Register request for {registrationRequest.Name} processed successfully"); return Ok(new { token }); } catch (UserAlreadyExistException ex) @@ -63,7 +65,7 @@ public class AuthController : Controller } [HttpPost("login")]// api/auth/login - [RequireHttps] + //[RequireHttps] public async Task Login([FromBody] LoginRequest userRequest) { try @@ -74,7 +76,7 @@ public class AuthController : Controller } var token = await _accountService.LoginAsync(userRequest.Name, userRequest.Password); - _logger.LogInformation($"Login request for {userRequest.Name}"); + _logger.LogInformation($"Login request for {userRequest.Name} processed successfully"); return Ok(new { token }); } catch (UserNotRegisteredException ex) diff --git a/Govor.API/Controllers/FriendsController.cs b/Govor.API/Controllers/FriendsController.cs index 32c48f5..c7092fc 100644 --- a/Govor.API/Controllers/FriendsController.cs +++ b/Govor.API/Controllers/FriendsController.cs @@ -26,7 +26,7 @@ public class FriendsController : Controller _currentUserService = currentUserService; } - [HttpGet("search")] + [HttpGet("search")] // api/friends/search?query= public async Task Search(string query) { if (string.IsNullOrWhiteSpace(query)) diff --git a/Govor.API/Govor.API.csproj b/Govor.API/Govor.API.csproj index 320becf..fe270b5 100644 --- a/Govor.API/Govor.API.csproj +++ b/Govor.API/Govor.API.csproj @@ -2,12 +2,11 @@ net9.0 - enable + disable enable - diff --git a/Govor.API/Program.cs b/Govor.API/Program.cs index bf1019a..ec3cea6 100644 --- a/Govor.API/Program.cs +++ b/Govor.API/Program.cs @@ -17,7 +17,7 @@ builder.Services.AddCors(options => { options.AddPolicy("AllowFrontend", policy => { - policy.WithOrigins("http://localhost:3000", "https://localhost:3000") + policy.WithOrigins("http://localhost:5000", "https://localhost:5000") .AllowAnyHeader() .AllowAnyMethod() .AllowCredentials(); @@ -106,7 +106,10 @@ if (app.Environment.IsDevelopment()) app.UseSwaggerUI(); } -app.UseHttpsRedirection(); + +app.UseCors(); + +//app.UseHttpsRedirection(); app.UseRouting(); diff --git a/Govor.Application/Interfaces/Authentication/IInvitesService.cs b/Govor.Application/Interfaces/Authentication/IInvitesService.cs index 5643ea3..ba5a9c9 100644 --- a/Govor.Application/Interfaces/Authentication/IInvitesService.cs +++ b/Govor.Application/Interfaces/Authentication/IInvitesService.cs @@ -4,6 +4,6 @@ namespace Govor.API.Services.Authentication.Interfaces; public interface IInvitesService { - public Task GetRole(User user); - public Invitation Validate(string inviteCode); + public Task GetRoleAsync(User user); + public Task ValidateAsync(string inviteCode); } \ No newline at end of file diff --git a/Govor.Application/Services/InvitesService.cs b/Govor.Application/Services/InvitesService.cs index 4942535..07b4f49 100644 --- a/Govor.Application/Services/InvitesService.cs +++ b/Govor.Application/Services/InvitesService.cs @@ -15,7 +15,7 @@ public class InvitesService : IInvitesService _invitesRepository = invitesRepository; } - public async Task GetRole(User user) + public async Task GetRoleAsync(User user) { try { @@ -28,21 +28,28 @@ public class InvitesService : IInvitesService } } - public Invitation Validate(string inviteCode) + public async Task ValidateAsync(string inviteCode) { - var invite = _invitesRepository.FindByCodeAsync(inviteCode).Result; - - if (invite.EndDate < DateTime.Now || - invite.MaxParticipants <= invite.Users.Count) + try + { + var invite = await _invitesRepository.FindByCodeAsync(inviteCode); + + if (invite.EndDate < DateTime.Now || invite.MaxParticipants <= invite.Users.Count) + { + invite.IsActive = false; + await _invitesRepository.UpdateAsync(invite); + throw new InviteLinkInvalidException(inviteCode); + } + + return invite; + } + catch (NotFoundByKeyException) { - invite.IsActive = false; - _invitesRepository.UpdateAsync(invite); throw new InviteLinkInvalidException(inviteCode); } - - return invite; } + public string GenerateInvitationLink(Invitation invitation) { throw new NotImplementedException(); diff --git a/Govor.Application/Services/JwtService.cs b/Govor.Application/Services/JwtService.cs index f4787ee..3c83155 100644 --- a/Govor.Application/Services/JwtService.cs +++ b/Govor.Application/Services/JwtService.cs @@ -24,7 +24,7 @@ public class JwtService : IJwtService var claims = new[] { new Claim("userID", user.Id.ToString()), - new Claim(ClaimTypes.Role, _invitesService.GetRole(user).Result, ClaimValueTypes.String) + new Claim(ClaimTypes.Role, _invitesService.GetRoleAsync(user).Result, ClaimValueTypes.String) }; var singing = new SigningCredentials( diff --git a/Govor.Contracts/Responses/Admins/UserResponse.cs b/Govor.Contracts/Responses/Admins/UserResponse.cs new file mode 100644 index 0000000..2cfc5e2 --- /dev/null +++ b/Govor.Contracts/Responses/Admins/UserResponse.cs @@ -0,0 +1,14 @@ +namespace Govor.Contracts.Responses.Admins; + +public class UserResponse +{ + public Guid Id { get; set; } + public string Username { get; set; } + public string Description { get; set; } + public string PasswordHash { get; set; } + public DateTime WasOnline { get; set; } + public DateOnly CreatedOn { get; set; } + public Guid IconId {get; set;} + public Guid InviteId {get; set;} + public bool IsAdmin {get; set;} +} \ No newline at end of file From cdae63b223460a5c43db9106b449b32582fcc1e7 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Tue, 1 Jul 2025 15:05:49 +0700 Subject: [PATCH 006/101] optimization sql --- .../AdminStuff/InviteUserController.cs | 32 ++++++++++--------- .../ConfigurationProgramExtensions.cs | 18 ++++++++--- .../Admins/InvitationResponses.cs} | 3 +- Govor.Data/Repositories/AdminsRepository.cs | 8 ++--- .../Exceptions/UpdateException.cs | 12 +++++-- .../Repositories/FriendshipsRepository.cs | 9 +++--- Govor.Data/Repositories/InvitesRepository.cs | 10 +++--- .../MediaAttachmentsRepository.cs | 9 +++--- Govor.Data/Repositories/MessagesRepository.cs | 12 ++++--- Govor.Data/Repositories/UsersRepository.cs | 26 ++++++++------- 10 files changed, 81 insertions(+), 58 deletions(-) rename Govor.Contracts/{DTOs/InvitationDto.cs => Responses/Admins/InvitationResponses.cs} (81%) diff --git a/Govor.API/Controllers/AdminStuff/InviteUserController.cs b/Govor.API/Controllers/AdminStuff/InviteUserController.cs index 0e2724b..372549a 100644 --- a/Govor.API/Controllers/AdminStuff/InviteUserController.cs +++ b/Govor.API/Controllers/AdminStuff/InviteUserController.cs @@ -52,13 +52,13 @@ public class InviteUserController : Controller _logger.LogInformation("Getting all active invitations by administrator"); var read = await _repository.GetAllAsync(); - var result = read.Where(x => x.IsActive == true).ToList(); + var result = read.Where(x => x.IsActive).ToList(); - List dtos = new List(); + List dtos = new List(); foreach (var inv in result) { - dtos.Add(new InvitationDto(){ + dtos.Add(new InvitationResponses(){ Id = inv.Id, Description = inv.Description, IsAdmin = inv.IsAdmin, @@ -67,6 +67,7 @@ public class InviteUserController : Controller CreatedAt = inv.DateCreated, EndAt = inv.EndDate, IsActive = inv.IsActive, + ParticipantCount = inv.Users.Count, }); } @@ -87,11 +88,11 @@ public class InviteUserController : Controller _logger.LogInformation("Getting all invitations by administrator"); var read = await _repository.GetAllAsync(); - List dtos = new List(); + List dtos = new List(); foreach (var inv in read) { - dtos.Add(new InvitationDto(){ + dtos.Add(new InvitationResponses(){ Id = inv.Id, Description = inv.Description, IsAdmin = inv.IsAdmin, @@ -100,6 +101,7 @@ public class InviteUserController : Controller CreatedAt = inv.DateCreated, EndAt = inv.EndDate, IsActive = inv.IsActive, + ParticipantCount = inv.Users.Count, }); } @@ -120,18 +122,18 @@ public class InviteUserController : Controller _logger.LogInformation("Getting invitations {id} by administrator"); var read = await _repository.FindByIdAsync(id); - var response = new InvitationDto(){ - Id = read.Id, - Description = read.Description, - IsAdmin = read.IsAdmin, - MaxParticipants = read.MaxParticipants, - Code = read.Code, - CreatedAt = read.DateCreated, - EndAt = read.EndDate, - IsActive = read.IsActive, + var response = new InvitationResponses(){ + Id = read.Id, + Description = read.Description, + IsAdmin = read.IsAdmin, + MaxParticipants = read.MaxParticipants, + Code = read.Code, + CreatedAt = read.DateCreated, + EndAt = read.EndDate, + IsActive = read.IsActive, + ParticipantCount = read.Users.Count, }; - return Ok(response); } catch (Exception e) diff --git a/Govor.API/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Extensions/ConfigurationProgramExtensions.cs index 15b3eeb..b09764b 100644 --- a/Govor.API/Extensions/ConfigurationProgramExtensions.cs +++ b/Govor.API/Extensions/ConfigurationProgramExtensions.cs @@ -67,19 +67,29 @@ public static class ConfigurationProgramExtensions public static void AddGovorDbContext(this IServiceCollection services, IConfiguration configuration) { - var useMySql = configuration.GetValue("UseMySql"); // Получаем значение из конфигурации + var useMySql = configuration.GetValue("UseMySql"); if (useMySql) { services.AddDbContext(options => - options.UseMySql(configuration.GetConnectionString(nameof(GovorDbContext)), - new MySqlServerVersion(new Version(8, 0, 21)))); + { + options + .UseMySql( + configuration.GetConnectionString(nameof(GovorDbContext)), + new MySqlServerVersion(new Version(8, 0, 21)) + ); + options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); + }); } else { services.AddDbContext(options => - options.UseNpgsql(configuration.GetConnectionString(nameof(GovorDbContext)))); + { + options.UseNpgsql(configuration.GetConnectionString(nameof(GovorDbContext))); + options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); + }); } } + } \ No newline at end of file diff --git a/Govor.Contracts/DTOs/InvitationDto.cs b/Govor.Contracts/Responses/Admins/InvitationResponses.cs similarity index 81% rename from Govor.Contracts/DTOs/InvitationDto.cs rename to Govor.Contracts/Responses/Admins/InvitationResponses.cs index 3cc7838..89fdf0d 100644 --- a/Govor.Contracts/DTOs/InvitationDto.cs +++ b/Govor.Contracts/Responses/Admins/InvitationResponses.cs @@ -1,6 +1,6 @@ namespace Govor.Contracts.DTOs; -public class InvitationDto +public class InvitationResponses { public Guid Id { get; set; } public bool IsAdmin { get; set; } @@ -9,5 +9,6 @@ public class InvitationDto public DateTime CreatedAt { get; set; } public DateTime EndAt { get; set; } public int MaxParticipants { get; set; } + public int ParticipantCount { get; set; } public string Description { get; set; } } \ No newline at end of file diff --git a/Govor.Data/Repositories/AdminsRepository.cs b/Govor.Data/Repositories/AdminsRepository.cs index 0e5783b..d6a050c 100644 --- a/Govor.Data/Repositories/AdminsRepository.cs +++ b/Govor.Data/Repositories/AdminsRepository.cs @@ -16,6 +16,7 @@ public class AdminsRepository(GovorDbContext context, IObjectValidator va return await _context.Admins .AsNoTracking() .Include(a => a.User) + .AsSplitQuery() .ToListOrThrowIfEmpty(new NotFoundException("Database is empty")); } @@ -24,6 +25,7 @@ public class AdminsRepository(GovorDbContext context, IObjectValidator va return await _context.Admins .AsNoTracking() .Include(a => a.User) + .AsSplitQuery() .FirstOrDefaultAsync(u => u.UserId == id) ?? throw new NotFoundByKeyException(id, "User with given id does not exist"); } @@ -60,11 +62,7 @@ public class AdminsRepository(GovorDbContext context, IObjectValidator va ); if (rowsAffected == 0) - throw new NotFoundByKeyException(admin.UserId); - } - catch (NotFoundByKeyException ex) - { - throw new UpdateException($"Not found admin by given id {admin.UserId}", ex); + throw new UpdateException($"Not found admin by given id {admin.UserId}"); } catch (Exception ex) { diff --git a/Govor.Data/Repositories/Exceptions/UpdateException.cs b/Govor.Data/Repositories/Exceptions/UpdateException.cs index 25c8a70..a44e54c 100644 --- a/Govor.Data/Repositories/Exceptions/UpdateException.cs +++ b/Govor.Data/Repositories/Exceptions/UpdateException.cs @@ -1,4 +1,12 @@ +using Govor.Core; + namespace Govor.Data.Repositories.Exceptions; -public class UpdateException(string s, Exception ex) - : Exception(s, ex); \ No newline at end of file +public class UpdateException : GovorCoreException +{ + public UpdateException(string s) + : base(s) { } + + public UpdateException(string s, Exception ex) + : base(s, ex) { } +} \ No newline at end of file diff --git a/Govor.Data/Repositories/FriendshipsRepository.cs b/Govor.Data/Repositories/FriendshipsRepository.cs index 1e7eed6..f2a0d7a 100644 --- a/Govor.Data/Repositories/FriendshipsRepository.cs +++ b/Govor.Data/Repositories/FriendshipsRepository.cs @@ -24,6 +24,7 @@ public class FriendshipsRepository : IFriendshipsRepository .AsNoTracking() .Include(x => x.Requester) .Include(x => x.Addressee) + .AsSplitQuery() .ToListOrThrowIfEmpty(new NotFoundException("Database is empty")); } @@ -33,6 +34,7 @@ public class FriendshipsRepository : IFriendshipsRepository .AsNoTracking() .Include(x => x.Requester) .Include(x => x.Addressee) + .AsSplitQuery() .FirstOrDefaultAsync(x => x.Id == id) ?? throw new NotFoundByKeyException(id, "Friendship with given id was not found"); } @@ -43,6 +45,7 @@ public class FriendshipsRepository : IFriendshipsRepository .AsNoTracking() .Include(x => x.Requester) .Include(x => x.Addressee) + .AsSplitQuery() .Where(x => x.RequesterId == userId) .ToListOrThrowIfEmpty(new NotFoundByKeyException(userId, "Friendship with given user id was not found")); } @@ -81,11 +84,7 @@ public class FriendshipsRepository : IFriendshipsRepository ); if (rowsAffected == 0) - throw new NotFoundByKeyException(friendship.Id); - } - catch (NotFoundByKeyException ex) - { - throw new UpdateException($"Not found friendship by given id {friendship.Id}", ex); + throw new UpdateException($"Not found friendship by given id {friendship.Id}"); } catch (Exception ex) { diff --git a/Govor.Data/Repositories/InvitesRepository.cs b/Govor.Data/Repositories/InvitesRepository.cs index 5ae9da4..9e2c2f3 100644 --- a/Govor.Data/Repositories/InvitesRepository.cs +++ b/Govor.Data/Repositories/InvitesRepository.cs @@ -23,6 +23,7 @@ public class InvitesRepository : IInvitesRepository return await _context.Invitations .AsNoTracking() .Include(i => i.Users) + .AsSplitQuery() .ToListOrThrowIfEmpty(new NotFoundException("Database is empty")); } @@ -31,6 +32,7 @@ public class InvitesRepository : IInvitesRepository return await _context.Invitations .AsNoTracking() .Include(i => i.Users) + .AsSplitQuery() .FirstOrDefaultAsync(i => i.Id == id) ?? throw new NotFoundByKeyException(id, "Invitation with given id does not exist"); } @@ -40,6 +42,7 @@ public class InvitesRepository : IInvitesRepository return await _context.Invitations .AsNoTracking() .Include(i => i.Users) + .AsSplitQuery() .FirstOrDefaultAsync(i => i.Code == code) ?? throw new NotFoundByKeyException(code, "Invitation with given code does not exist"); } @@ -49,6 +52,7 @@ public class InvitesRepository : IInvitesRepository return await _context.Invitations .AsNoTracking() .Include(i => i.Users) + .AsSplitQuery() .Where(i => i.IsAdmin) .ToListOrThrowIfEmpty(new NotFoundByKeyException(true, "Admins invites do not exist")); } @@ -94,11 +98,7 @@ public class InvitesRepository : IInvitesRepository ); if (rowsAffected == 0) - throw new NotFoundByKeyException(invitation.Id, "Invitation with given data invalid"); - } - catch (NotFoundByKeyException ex) - { - throw new UpdateException($"Not found invitation by given id {invitation.Id}", ex); + throw new UpdateException($"Not found invitation by given id {invitation.Id}"); } catch (Exception ex) { diff --git a/Govor.Data/Repositories/MediaAttachmentsRepository.cs b/Govor.Data/Repositories/MediaAttachmentsRepository.cs index 83ded41..391c18c 100644 --- a/Govor.Data/Repositories/MediaAttachmentsRepository.cs +++ b/Govor.Data/Repositories/MediaAttachmentsRepository.cs @@ -23,6 +23,7 @@ public class MediaAttachmentsRepository : IMediaAttachmentsRepository return await _context.MediaAttachments .AsNoTracking() .Include(ma => ma.Message) + .AsSplitQuery() .ToListOrThrowIfEmpty(new NotFoundException("No media attachments found.")); } @@ -31,6 +32,7 @@ public class MediaAttachmentsRepository : IMediaAttachmentsRepository return await _context.MediaAttachments .AsNoTracking() .Include(ma => ma.Message) + .AsSplitQuery() .Where(m => m.MessageId == messageId) .ToListOrThrowIfEmpty(new NotFoundByKeyException(messageId, "No media attachments found by given message Id")); } @@ -40,6 +42,7 @@ public class MediaAttachmentsRepository : IMediaAttachmentsRepository return await _context.MediaAttachments .AsNoTracking() .Include(ma => ma.Message) + .AsSplitQuery() .FirstOrDefaultAsync(m => m.Id == id) ?? throw new NotFoundByKeyException(id, "No media attachments found by given Id"); } @@ -80,11 +83,7 @@ public class MediaAttachmentsRepository : IMediaAttachmentsRepository ); if (rowsAffected == 0) - throw new NotFoundByKeyException(attachments.Id); - } - catch (NotFoundByKeyException ex) - { - throw new UpdateException($"Not found attachments by given id {attachments.Id}", ex); + throw new UpdateException($"Not found attachments by given id {attachments.Id}"); } catch (Exception ex) { diff --git a/Govor.Data/Repositories/MessagesRepository.cs b/Govor.Data/Repositories/MessagesRepository.cs index 3da8cd6..bad8ce2 100644 --- a/Govor.Data/Repositories/MessagesRepository.cs +++ b/Govor.Data/Repositories/MessagesRepository.cs @@ -22,6 +22,7 @@ public class MessagesRepository : IMessagesRepository return await _context.Messages .AsNoTracking() .Include(m => m.ReplyToMessage) + .AsSplitQuery() .ToListOrThrowIfEmpty(new NotFoundException("No messages found in the database")); } @@ -30,6 +31,7 @@ public class MessagesRepository : IMessagesRepository return await _context.Messages .AsNoTracking() .Include(m => m.ReplyToMessage) + .AsSplitQuery() .FirstOrDefaultAsync(m => m.Id == messageId) ?? throw new NotFoundByKeyException(messageId, "Message with given id does not exist"); } @@ -39,6 +41,7 @@ public class MessagesRepository : IMessagesRepository return await _context.Messages .AsNoTracking() .Include(m => m.ReplyToMessage) + .AsSplitQuery() .Where(m => m.SenderId == senderId) .ToListOrThrowIfEmpty(new NotFoundByKeyException(senderId, "Messages with given sender id do not exist")); } @@ -48,6 +51,7 @@ public class MessagesRepository : IMessagesRepository return await _context.Messages .AsNoTracking() .Include(m => m.ReplyToMessage) + .AsSplitQuery() .Where(m => m.RecipientId == receiverId) .ToListOrThrowIfEmpty(new NotFoundByKeyException(receiverId, "Messages with given recipient id do not exist")); } @@ -57,6 +61,7 @@ public class MessagesRepository : IMessagesRepository return await _context.Messages .AsNoTracking() .Include(m => m.ReplyToMessage) + .AsSplitQuery() .Where(m => m.SenderId == senderId && m.RecipientId == receiverId && m.RecipientType == recipientType) @@ -68,6 +73,7 @@ public class MessagesRepository : IMessagesRepository return await _context.Messages .AsNoTracking() .Include(m => m.ReplyToMessage) + .AsSplitQuery() .Where(m => m.SentAt == date) .ToListOrThrowIfEmpty(new NotFoundByKeyException(date, "Messages sent at date do not exist")); } @@ -116,11 +122,7 @@ public class MessagesRepository : IMessagesRepository ); if (rowsAffected == 0) - throw new NotFoundByKeyException(message.Id); - } - catch (NotFoundByKeyException ex) - { - throw new UpdateException($"Not found message by given id {message.Id}", ex); + throw new UpdateException($"Not found message by given id {message.Id}"); } catch (Exception ex) { diff --git a/Govor.Data/Repositories/UsersRepository.cs b/Govor.Data/Repositories/UsersRepository.cs index d665b8d..c9822aa 100644 --- a/Govor.Data/Repositories/UsersRepository.cs +++ b/Govor.Data/Repositories/UsersRepository.cs @@ -25,6 +25,7 @@ public class UsersRepository : IUsersRepository .Include(u => u.Invite) .Include(u => u.ReceivedFriendRequests) .Include(u => u.SentFriendRequests) + .AsSplitQuery() .ToListOrThrowIfEmpty(new NotFoundException("Users in Database not exists")); } @@ -38,6 +39,7 @@ public class UsersRepository : IUsersRepository .Include(u => u.Invite) .Include(u => u.ReceivedFriendRequests) .Include(u => u.SentFriendRequests) + .AsSplitQuery() .FirstOrDefaultAsync(x => x.Id == id) ?? throw new NotFoundByKeyException(id, "User with given id does not exist"); } @@ -53,6 +55,7 @@ public class UsersRepository : IUsersRepository .Include(u => u.Invite) .Include(u => u.ReceivedFriendRequests) .Include(u => u.SentFriendRequests) + .AsSplitQuery() .ToListOrThrowIfEmpty(new NotFoundByKeyException>(ids,"Users with given ids not found")); } @@ -66,6 +69,7 @@ public class UsersRepository : IUsersRepository .Include(u => u.Invite) .Include(u => u.ReceivedFriendRequests) .Include(u => u.SentFriendRequests) + .AsSplitQuery() .FirstOrDefaultAsync(x => x.Username == username) ?? throw new NotFoundByKeyException(username, "User with given username does not exist"); } @@ -77,6 +81,7 @@ public class UsersRepository : IUsersRepository .Include(u => u.Invite) .Include(u => u.ReceivedFriendRequests) .Include(u => u.SentFriendRequests) + .AsSplitQuery() .Where(u => u.Id != currentUserId && u.Username.ToLower().Contains(query.ToLower()) && !_context.Friendships.Any(f => @@ -99,6 +104,7 @@ public class UsersRepository : IUsersRepository .Include(u => u.Invite) .Include(u => u.ReceivedFriendRequests) .Include(u => u.SentFriendRequests) + .AsSplitQuery() .ToListOrThrowIfEmpty(new NotFoundByKeyException>(usernames, "Users with given usernames not found")); } @@ -113,6 +119,7 @@ public class UsersRepository : IUsersRepository .Include(u => u.Invite) .Include(u => u.ReceivedFriendRequests) .Include(u => u.SentFriendRequests) + .AsSplitQuery() .ToListOrThrowIfEmpty(new NotFoundByKeyException(createdDate, "Users with given created date do not exist")); } @@ -145,20 +152,17 @@ public class UsersRepository : IUsersRepository var rowsAffected = await _context.Users .Where(u => u.Id == user.Id) .ExecuteUpdateAsync(u => u - .SetProperty(a => a.Username, user.Username) - .SetProperty(u => u.IconId, user.IconId) - .SetProperty(u => u.Description, user.Description) - .SetProperty(u => u.CreatedOn, user.CreatedOn) - .SetProperty(u => u.PasswordHash, user.PasswordHash) - .SetProperty(u => u.WasOnline, user.WasOnline) + .SetProperty(a => a.Username, user.Username) + .SetProperty(u => u.IconId, user.IconId) + .SetProperty(u => u.Description, user.Description) + .SetProperty(u => u.CreatedOn, user.CreatedOn) + .SetProperty(u => u.PasswordHash, user.PasswordHash) + .SetProperty(u => u.WasOnline, user.WasOnline) + .SetProperty(u => u.InviteId, user.InviteId) ); if (rowsAffected == 0) - throw new NotFoundByKeyException(user.Id); - } - catch (NotFoundByKeyException ex) - { - throw new UpdateException($"Not found user by given id {user.Id}", ex); + throw new UpdateException($"Not found user by given id {user.Id}"); } catch (Exception ex) { From b84d641bb564e40692440a74a940962f0368103d Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Tue, 1 Jul 2025 15:46:41 +0700 Subject: [PATCH 007/101] new logger --- .../Extensions/ConfiguratorLoggerExtensions.cs | 17 +++++++++++++++++ Govor.API/Govor.API.csproj | 1 + Govor.API/Program.cs | 9 +++++++-- 3 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 Govor.API/Extensions/ConfiguratorLoggerExtensions.cs diff --git a/Govor.API/Extensions/ConfiguratorLoggerExtensions.cs b/Govor.API/Extensions/ConfiguratorLoggerExtensions.cs new file mode 100644 index 0000000..6945925 --- /dev/null +++ b/Govor.API/Extensions/ConfiguratorLoggerExtensions.cs @@ -0,0 +1,17 @@ +using Serilog; + +namespace Govor.API.Extensions; + +public static class ConfiguratorLoggerExtensions +{ + public static void AddLogger(this WebApplicationBuilder builder) + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Information() + .WriteTo.Console() + .WriteTo.File("Logs/log-.txt", rollingInterval: RollingInterval.Day) // Лог в файл, ежедневно + .CreateLogger(); + + builder.Host.UseSerilog(); + } +} \ No newline at end of file diff --git a/Govor.API/Govor.API.csproj b/Govor.API/Govor.API.csproj index fe270b5..71de3d2 100644 --- a/Govor.API/Govor.API.csproj +++ b/Govor.API/Govor.API.csproj @@ -18,6 +18,7 @@ + diff --git a/Govor.API/Program.cs b/Govor.API/Program.cs index ec3cea6..402aaad 100644 --- a/Govor.API/Program.cs +++ b/Govor.API/Program.cs @@ -11,16 +11,21 @@ var builder = WebApplication.CreateBuilder(args); var configuration = builder.Configuration; var services = builder.Services; +builder.AddLogger();// Serilog + builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true); builder.Services.AddCors(options => { options.AddPolicy("AllowFrontend", policy => { - policy.WithOrigins("http://localhost:5000", "https://localhost:5000") + /*policy.WithOrigins("http://localhost:5000", "https://localhost:5000") .AllowAnyHeader() .AllowAnyMethod() - .AllowCredentials(); + .AllowCredentials();*/ + policy.AllowAnyOrigin() + .AllowAnyHeader() + .AllowAnyMethod(); }); }); From e061725a99c4437cf09a909e480402e513ed53f3 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Tue, 1 Jul 2025 19:17:01 +0700 Subject: [PATCH 008/101] downcast to .net 8.x --- Govor.API.Tests/Govor.API.Tests.csproj | 15 ++++++----- Govor.API/Govor.API.csproj | 25 ++++++++----------- Govor.Application/Govor.Application.csproj | 15 +++++------ .../Services/LocalStorageService.cs | 1 - Govor.Console/Govor.Console.csproj | 12 ++++----- Govor.Contracts/Govor.Contracts.csproj | 2 +- Govor.Core/Govor.Core.csproj | 4 +-- Govor.Data/Govor.Data.csproj | 10 ++++---- global.json | 7 ++++++ 9 files changed, 47 insertions(+), 44 deletions(-) create mode 100644 global.json diff --git a/Govor.API.Tests/Govor.API.Tests.csproj b/Govor.API.Tests/Govor.API.Tests.csproj index 139f439..541926b 100644 --- a/Govor.API.Tests/Govor.API.Tests.csproj +++ b/Govor.API.Tests/Govor.API.Tests.csproj @@ -1,7 +1,7 @@  - net9.0 + net8.0 latest enable enable @@ -11,13 +11,12 @@ - - + + - - - + + @@ -25,8 +24,8 @@ - - + + diff --git a/Govor.API/Govor.API.csproj b/Govor.API/Govor.API.csproj index 71de3d2..b88211f 100644 --- a/Govor.API/Govor.API.csproj +++ b/Govor.API/Govor.API.csproj @@ -1,30 +1,27 @@ - net9.0 + net8.0 disable enable - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - + + + + - - - + + + - - + + + diff --git a/Govor.Application/Govor.Application.csproj b/Govor.Application/Govor.Application.csproj index 24f32c2..703ef94 100644 --- a/Govor.Application/Govor.Application.csproj +++ b/Govor.Application/Govor.Application.csproj @@ -1,21 +1,22 @@  - net9.0 + net8.0 enable enable - - + + - - - - + + + + + diff --git a/Govor.Application/Services/LocalStorageService.cs b/Govor.Application/Services/LocalStorageService.cs index 2b7e1fc..4ac849b 100644 --- a/Govor.Application/Services/LocalStorageService.cs +++ b/Govor.Application/Services/LocalStorageService.cs @@ -1,5 +1,4 @@ using Govor.Application.Interfaces; -using Microsoft.AspNetCore.Hosting; namespace Govor.Application.Services; public class LocalStorageService : IStorageService diff --git a/Govor.Console/Govor.Console.csproj b/Govor.Console/Govor.Console.csproj index 49d4e74..2725238 100644 --- a/Govor.Console/Govor.Console.csproj +++ b/Govor.Console/Govor.Console.csproj @@ -2,21 +2,21 @@ Exe - net9.0 + net8.0 13 enable enable - - - + + - - + + + diff --git a/Govor.Contracts/Govor.Contracts.csproj b/Govor.Contracts/Govor.Contracts.csproj index 4f3150c..20e0a8f 100644 --- a/Govor.Contracts/Govor.Contracts.csproj +++ b/Govor.Contracts/Govor.Contracts.csproj @@ -1,7 +1,7 @@  - net9.0 + net8.0 enable enable diff --git a/Govor.Core/Govor.Core.csproj b/Govor.Core/Govor.Core.csproj index 1f027dd..eaa57c8 100644 --- a/Govor.Core/Govor.Core.csproj +++ b/Govor.Core/Govor.Core.csproj @@ -1,13 +1,13 @@  - net9.0 + net8.0 enable enable - + diff --git a/Govor.Data/Govor.Data.csproj b/Govor.Data/Govor.Data.csproj index 0b6f472..036ffa3 100644 --- a/Govor.Data/Govor.Data.csproj +++ b/Govor.Data/Govor.Data.csproj @@ -1,19 +1,19 @@  - net9.0 + net8.0 enable enable - - - + + + - + diff --git a/global.json b/global.json new file mode 100644 index 0000000..dad2db5 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "8.0.0", + "rollForward": "latestMajor", + "allowPrerelease": true + } +} \ No newline at end of file From 42e58dad9fbe3338ad74ce5b90cfb94c6599a2b8 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Tue, 1 Jul 2025 19:49:43 +0700 Subject: [PATCH 009/101] dockerfile add --- Dockerfile | 30 ++++++++++++++++++++++++++++++ Govor.Console/FriendsClient.cs | 1 - Govor.sln | 3 +++ 3 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c09fdf5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +# Используем официальный образ с SDK для сборки +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /app + +# Копируем csproj и восстанавливаем зависимости +COPY *.sln . +COPY Govor.API/*.csproj ./Govor.API/ +COPY Govor.Application/*.csproj ./Govor.Application/ +COPY Govor.Core/*.csproj ./Govor.Core/ +COPY Govor.Data/*.csproj ./Govor.Data/ +RUN dotnet restore + +# Копируем все исходники и билдим проект в Release режиме +COPY . . +WORKDIR /app/Govor.API +RUN dotnet publish -c Release -o out + +# Используем лёгкий runtime образ для запуска +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime +WORKDIR /app + +# Копируем билд из предыдущего этапа +COPY --from=build /app/Govor.API/out ./ + +# Указываем порт (если используется нестандартный, замени 80) +EXPOSE 80 +EXPOSE 443 + +# Запускаем приложение +ENTRYPOINT ["dotnet", "Govor.API.dll"] diff --git a/Govor.Console/FriendsClient.cs b/Govor.Console/FriendsClient.cs index 21d327a..1accf06 100644 --- a/Govor.Console/FriendsClient.cs +++ b/Govor.Console/FriendsClient.cs @@ -1,4 +1,3 @@ -using System.Net.Http; using System.Net.Http.Json; using Govor.Contracts.DTOs; diff --git a/Govor.sln b/Govor.sln index 2a9769a..d3eeb87 100644 --- a/Govor.sln +++ b/Govor.sln @@ -13,6 +13,9 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{4ED5259A-6FB4-4D89-8E6B-4778DC68F7D4}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{114F53C1-B0AB-4BA0-9E36-0E811D1B3776}" + ProjectSection(SolutionItems) = preProject + Dockerfile = Dockerfile + EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Govor.Contracts", "Govor.Contracts\Govor.Contracts.csproj", "{4E94907F-BE20-42A6-AB15-637850FEAD11}" EndProject From b9e2b5d417d07fe62074ba0f8ef3182570cd8ab0 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Tue, 1 Jul 2025 19:53:12 +0700 Subject: [PATCH 010/101] update --- Dockerfile | 3 +-- Govor.Console/FriendsClient.cs | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index c09fdf5..04dd83a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,8 +23,7 @@ WORKDIR /app COPY --from=build /app/Govor.API/out ./ # Указываем порт (если используется нестандартный, замени 80) -EXPOSE 80 -EXPOSE 443 +EXPOSE 8080 # Запускаем приложение ENTRYPOINT ["dotnet", "Govor.API.dll"] diff --git a/Govor.Console/FriendsClient.cs b/Govor.Console/FriendsClient.cs index 1accf06..f43b462 100644 --- a/Govor.Console/FriendsClient.cs +++ b/Govor.Console/FriendsClient.cs @@ -12,6 +12,7 @@ namespace Govor.ConsoleClient _client = client; } + // ge public async Task> SearchAsync(string query) { var response = await _client.GetAsync($"/api/friends/search?query={query}"); From cab30a79eb510d2bed77be23ae5ebfb9aa4051a8 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Tue, 1 Jul 2025 20:03:32 +0700 Subject: [PATCH 011/101] Update dotnet.yml --- .github/workflows/dotnet.yml | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index c383e46..095658c 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -10,21 +10,23 @@ on: branches: [ "master" ] jobs: - build: - + build-and-test: runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 9.0.x - - name: Restore dependencies - run: dotnet restore - - name: Build - run: dotnet build --no-restore --configuration Release - - name: Test API - run: dotnet test ./Govor.API.Tests/Govor.API.Tests.csproj + - name: Checkout code + uses: actions/checkout@v4 - \ No newline at end of file + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --no-restore --configuration Release + + - name: Run tests + run: dotnet test --no-build --verbosity normal + From e6e1dd6e92e4f2f00e855898560abe1f4a864f16 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Tue, 1 Jul 2025 20:07:53 +0700 Subject: [PATCH 012/101] LangVersion 12 --- Govor.Console/FriendsClient.cs | 3 +-- Govor.Console/Govor.Console.csproj | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Govor.Console/FriendsClient.cs b/Govor.Console/FriendsClient.cs index f43b462..19e9b2d 100644 --- a/Govor.Console/FriendsClient.cs +++ b/Govor.Console/FriendsClient.cs @@ -11,8 +11,7 @@ namespace Govor.ConsoleClient { _client = client; } - - // ge + public async Task> SearchAsync(string query) { var response = await _client.GetAsync($"/api/friends/search?query={query}"); diff --git a/Govor.Console/Govor.Console.csproj b/Govor.Console/Govor.Console.csproj index 2725238..6f99954 100644 --- a/Govor.Console/Govor.Console.csproj +++ b/Govor.Console/Govor.Console.csproj @@ -3,7 +3,7 @@ Exe net8.0 - 13 + 12 enable enable From e43d3a2c8f9b5085246c4cc845f3f1029298f952 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Tue, 1 Jul 2025 20:10:25 +0700 Subject: [PATCH 013/101] Update dotnet.yml --- .github/workflows/dotnet.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 095658c..d1b9299 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -28,5 +28,6 @@ jobs: run: dotnet build --no-restore --configuration Release - name: Run tests - run: dotnet test --no-build --verbosity normal + run: dotnet test Govor.API.Tests/Govor.API.Tests.csproj --no-build --verbosity normal + From 88c4210ad6a6fd7c600840cbb3a729703074e5ec Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Tue, 1 Jul 2025 20:16:13 +0700 Subject: [PATCH 014/101] Update dotnet.yml --- .github/workflows/dotnet.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index d1b9299..b0d56a7 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -28,6 +28,6 @@ jobs: run: dotnet build --no-restore --configuration Release - name: Run tests - run: dotnet test Govor.API.Tests/Govor.API.Tests.csproj --no-build --verbosity normal + run: dotnet test Govor.API.Tests/Govor.API.Tests.csproj From f90b124ba35e4c54ea2030850ad0a8ba0f9cd744 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Tue, 1 Jul 2025 20:49:14 +0700 Subject: [PATCH 015/101] Update Program.cs --- Govor.API/Program.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/Govor.API/Program.cs b/Govor.API/Program.cs index 402aaad..3e8940f 100644 --- a/Govor.API/Program.cs +++ b/Govor.API/Program.cs @@ -19,13 +19,10 @@ builder.Services.AddCors(options => { options.AddPolicy("AllowFrontend", policy => { - /*policy.WithOrigins("http://localhost:5000", "https://localhost:5000") + policy.WithOrigins("http://localhost:5000", "https://5.129.212.144:5000") // Укажите ваш публичный IP .AllowAnyHeader() .AllowAnyMethod() - .AllowCredentials();*/ - policy.AllowAnyOrigin() - .AllowAnyHeader() - .AllowAnyMethod(); + .AllowCredentials(); }); }); From 2ce7b6e7f9230d07c5b06823177713000420d4a5 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Tue, 1 Jul 2025 21:09:05 +0700 Subject: [PATCH 016/101] UseHttpsRedirection --- Govor.API/Program.cs | 4 ++-- Govor.Console/Program.cs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Govor.API/Program.cs b/Govor.API/Program.cs index 3e8940f..3eb0e4b 100644 --- a/Govor.API/Program.cs +++ b/Govor.API/Program.cs @@ -109,9 +109,9 @@ if (app.Environment.IsDevelopment()) } -app.UseCors(); +app.UseCors("AllowFrontend"); -//app.UseHttpsRedirection(); +app.UseHttpsRedirection(); app.UseRouting(); diff --git a/Govor.Console/Program.cs b/Govor.Console/Program.cs index 817720a..0d421ee 100644 --- a/Govor.Console/Program.cs +++ b/Govor.Console/Program.cs @@ -23,7 +23,7 @@ namespace Govor.ConsoleClient class Program { static string? AuthToken = null; - static HttpClientService HttpService = new("https://localhost:7155"); // поменяй URL на свой + static HttpClientService HttpService = new("http://5.129.212.144:5041"); // поменяй URL на свой private static FriendsClient friendsClient; static Dictionary> ChatHistory = new(); static string CurrentChatUser = null; @@ -67,7 +67,7 @@ namespace Govor.ConsoleClient { AuthToken = await HttpService.LoginAsync(loginUsername, loginPassword); HttpClient sharedClient = new(); - sharedClient.BaseAddress = new Uri("https://localhost:7155"); + sharedClient.BaseAddress = new Uri("http://5.129.212.144:5041"); sharedClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", AuthToken); friendsClient = new FriendsClient(sharedClient); @@ -92,7 +92,7 @@ namespace Govor.ConsoleClient { AuthToken = await HttpService.RegisterAsync(regUsername, regPassword, inviteCode); HttpClient sharedClient = new(); - sharedClient.BaseAddress = new Uri("https://localhost:7155"); + sharedClient.BaseAddress = new Uri("http://5.129.212.144:5041"); sharedClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", AuthToken); friendsClient = new FriendsClient(sharedClient); From 21a377eff3f7abe1bc53990906b91ce0d0460900 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Tue, 1 Jul 2025 21:27:04 +0700 Subject: [PATCH 017/101] UseSwagger --- Govor.API/Program.cs | 5 +++-- Govor.Console/Program.cs | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Govor.API/Program.cs b/Govor.API/Program.cs index 3eb0e4b..761a772 100644 --- a/Govor.API/Program.cs +++ b/Govor.API/Program.cs @@ -104,10 +104,11 @@ var app = builder.Build(); if (app.Environment.IsDevelopment()) { //app.MapOpenApi(); - app.UseSwagger(); - app.UseSwaggerUI(); + } +app.UseSwagger(); +app.UseSwaggerUI(); app.UseCors("AllowFrontend"); diff --git a/Govor.Console/Program.cs b/Govor.Console/Program.cs index 0d421ee..2f77dc7 100644 --- a/Govor.Console/Program.cs +++ b/Govor.Console/Program.cs @@ -23,7 +23,7 @@ namespace Govor.ConsoleClient class Program { static string? AuthToken = null; - static HttpClientService HttpService = new("http://5.129.212.144:5041"); // поменяй URL на свой + static HttpClientService HttpService = new("https://govor-team-govor-88b3.twc1.net"); // поменяй URL на свой private static FriendsClient friendsClient; static Dictionary> ChatHistory = new(); static string CurrentChatUser = null; @@ -67,7 +67,7 @@ namespace Govor.ConsoleClient { AuthToken = await HttpService.LoginAsync(loginUsername, loginPassword); HttpClient sharedClient = new(); - sharedClient.BaseAddress = new Uri("http://5.129.212.144:5041"); + sharedClient.BaseAddress = new Uri("https://govor-team-govor-88b3.twc1.net"); sharedClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", AuthToken); friendsClient = new FriendsClient(sharedClient); @@ -92,7 +92,7 @@ namespace Govor.ConsoleClient { AuthToken = await HttpService.RegisterAsync(regUsername, regPassword, inviteCode); HttpClient sharedClient = new(); - sharedClient.BaseAddress = new Uri("http://5.129.212.144:5041"); + sharedClient.BaseAddress = new Uri("https://govor-team-govor-88b3.twc1.net"); sharedClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", AuthToken); friendsClient = new FriendsClient(sharedClient); From 48e519118e74690d098779831d97ee5279422a1c Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Tue, 1 Jul 2025 21:42:34 +0700 Subject: [PATCH 018/101] hot fix jwt --- Govor.Application/Services/JwtService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Govor.Application/Services/JwtService.cs b/Govor.Application/Services/JwtService.cs index 3c83155..bf0c37c 100644 --- a/Govor.Application/Services/JwtService.cs +++ b/Govor.Application/Services/JwtService.cs @@ -23,7 +23,7 @@ public class JwtService : IJwtService { var claims = new[] { - new Claim("userID", user.Id.ToString()), + new Claim("userId", user.Id.ToString()), new Claim(ClaimTypes.Role, _invitesService.GetRoleAsync(user).Result, ClaimValueTypes.String) }; From 0967fe1b4e4adb819d44396b2aaadf1231901e07 Mon Sep 17 00:00:00 2001 From: stalcker2288969 Date: Wed, 2 Jul 2025 12:01:48 +0700 Subject: [PATCH 019/101] gitignore update for logs --- Govor.API/Extensions/ConfiguratorLoggerExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Govor.API/Extensions/ConfiguratorLoggerExtensions.cs b/Govor.API/Extensions/ConfiguratorLoggerExtensions.cs index 6945925..1ef16b4 100644 --- a/Govor.API/Extensions/ConfiguratorLoggerExtensions.cs +++ b/Govor.API/Extensions/ConfiguratorLoggerExtensions.cs @@ -9,7 +9,7 @@ public static class ConfiguratorLoggerExtensions Log.Logger = new LoggerConfiguration() .MinimumLevel.Information() .WriteTo.Console() - .WriteTo.File("Logs/log-.txt", rollingInterval: RollingInterval.Day) // Лог в файл, ежедневно + .WriteTo.File("logs/log-.txt", rollingInterval: RollingInterval.Day) // Лог в файл, ежедневно .CreateLogger(); builder.Host.UseSerilog(); From 0669614a5e5ae358c1adf4d40085e66353084e70 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Wed, 2 Jul 2025 15:26:27 +0700 Subject: [PATCH 020/101] async Task GenerateJwtTokenAsync(User user) --- .../Authentication/JwtServiceTests.cs | 4 +- .../Controllers/AdminStuff/UsersController.cs | 27 ++++++++-- Govor.API/Controllers/MediaController.cs | 50 +++++++++++++++++++ Govor.API/Hubs/ChatsHub.cs | 19 +++---- .../Interfaces/AdminsStuff/UsersService.cs | 7 +++ .../Interfaces/Authentication/IJwtService.cs | 2 +- .../Interfaces/IUsersAdministration.cs | 1 + Govor.Application/Services/AuthService.cs | 4 +- Govor.Application/Services/JwtService.cs | 4 +- .../Requests/MediaUploadRequest.cs | 13 +++++ .../Requests/SignalR/MediaReference.cs | 11 ++++ .../Requests/SignalR/MessageRequest.cs | 9 ++++ Govor.Core/Models/MediaAttachments.cs | 2 - 13 files changed, 131 insertions(+), 22 deletions(-) create mode 100644 Govor.API/Controllers/MediaController.cs create mode 100644 Govor.Contracts/Requests/MediaUploadRequest.cs create mode 100644 Govor.Contracts/Requests/SignalR/MediaReference.cs create mode 100644 Govor.Contracts/Requests/SignalR/MessageRequest.cs diff --git a/Govor.API.Tests/UnitTests/Services/Authentication/JwtServiceTests.cs b/Govor.API.Tests/UnitTests/Services/Authentication/JwtServiceTests.cs index 2eab678..9e4acb8 100644 --- a/Govor.API.Tests/UnitTests/Services/Authentication/JwtServiceTests.cs +++ b/Govor.API.Tests/UnitTests/Services/Authentication/JwtServiceTests.cs @@ -40,14 +40,14 @@ public class JwtServiceTests } [Test] - public void GenerateJwtToken_ShouldReturnValidJwtString() + public async Task GenerateJwtToken_ShouldReturnValidJwtString() { // Arrange var user = _fixture.Create(); var expectedRole = "User"; _invitesServiceMock.Setup(s => s.GetRoleAsync(user)).Returns(Task.FromResult(expectedRole)); // Act - var tokenString = _jwtService.GenerateJwtToken(user); + var tokenString = await _jwtService.GenerateJwtTokenAsync(user); // Assert Assert.That(tokenString, Is.Not.Null.And.Not.Empty); diff --git a/Govor.API/Controllers/AdminStuff/UsersController.cs b/Govor.API/Controllers/AdminStuff/UsersController.cs index fb2e97d..8dd1c6a 100644 --- a/Govor.API/Controllers/AdminStuff/UsersController.cs +++ b/Govor.API/Controllers/AdminStuff/UsersController.cs @@ -24,16 +24,35 @@ public class UsersController : Controller [HttpGet] public async Task AllUsers() { - _logger.LogInformation("Getting all users by administrator"); - var read = await _users.GetAllUsersAsync(); + try + { + _logger.LogInformation("Getting all users by administrator"); + var read = await _users.GetAllUsersAsync(); - return Ok(BuildUserDtos(read)); + return Ok(BuildUserDtos(read)); + } + catch (Exception e) + { + _logger.LogError(e, e.Message); + return StatusCode(500, e.Message); + } + } [HttpGet("{id:guid}")] public async Task GetUserById(Guid id) { - return Ok(id); + try + { + _logger.LogInformation($"Getting user {id} by administrator"); + var read = await _users.GetUserById(id); + return Ok(BuildUserDtos([read]).First()); + } + catch (Exception e) + { + _logger.LogError(e, e.Message); + return StatusCode(500, e.Message); + } } private List BuildUserDtos(IEnumerable users) => users.Select(user => new UserResponse diff --git a/Govor.API/Controllers/MediaController.cs b/Govor.API/Controllers/MediaController.cs new file mode 100644 index 0000000..947c9cf --- /dev/null +++ b/Govor.API/Controllers/MediaController.cs @@ -0,0 +1,50 @@ +using Govor.Application.Interfaces; +using Govor.Contracts.Requests; +using Govor.Core.Models; +using Govor.Core.Repositories.MediasAttachments; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Govor.API.Controllers; + +[ApiController] +[Route("api/media")] +[Authorize(Roles = "User,Admin")] +public class MediaController : Controller +{ + private readonly ILogger _logger; + private readonly IStorageService _storageService; + private readonly IMediaAttachmentsRepository _repository; + + public MediaController(ILogger logger, IStorageService storageService) + { + _logger = logger; + _storageService = storageService; + } + + [HttpPost("upload")] + [RequestSizeLimit(100_000_000)]// ~100MB + public async Task Upload([FromForm] MediaUploadRequest request) + { + try + { + var url = await _storageService.SaveAsync(request.Data,request.FileName); + var mediaId = Guid.NewGuid(); + + _repository.AddAsync(new MediaAttachments() + { + Id = mediaId, + FilePath = url, + EncryptedKey = request.EncryptedKey, + MimeType = request.MimeType, + Type = request.Type, + }); + + return Ok(mediaId); + } + catch (Exception ex) + { + return StatusCode(500, ex); + } + } +} \ No newline at end of file diff --git a/Govor.API/Hubs/ChatsHub.cs b/Govor.API/Hubs/ChatsHub.cs index b959c3f..e0ffe2d 100644 --- a/Govor.API/Hubs/ChatsHub.cs +++ b/Govor.API/Hubs/ChatsHub.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using Govor.Contracts.Requests.SignalR; using Govor.Core.Models; using Govor.Core.Repositories.Users; using Govor.Data.Repositories.Exceptions; @@ -48,19 +49,19 @@ public class ChatsHub : Hub } - public async Task Send(string message, Guid toUserId) + public async Task Send(MessageRequest request) { // Валидация входных данных - if (string.IsNullOrWhiteSpace(message)) + if (string.IsNullOrWhiteSpace(request.EncryptedContent)) { _logger.LogWarning("Empty message received from user {UserId}", GetUserId()); - throw new ArgumentException("Message cannot be empty", nameof(message)); + throw new ArgumentException("Message cannot be empty", nameof(request.EncryptedContent)); } var senderId = GetUserId(); // Проверка существования получателя - try + /*try { await _usersRepository.FindByIdAsync(toUserId); } @@ -73,19 +74,19 @@ public class ChatsHub : Hub { _logger.LogWarning("Invalid recipient userId received from user {UserId}", GetUserId()); throw; - } + }*/ try { - _logger.LogInformation("Message sent from {SenderId} to {RecipientId}: {Message}", senderId, toUserId, message); + _logger.LogInformation("Message sent from {SenderId} to {RecipientId} at {UtcNow}", senderId, request.RecipientId, DateTime.UtcNow); // Отправка сообщения отправителю и получателю - await Clients.Group(toUserId.ToString()).SendAsync("Receive", message, senderId); - await Clients.Group(senderId.ToString()).SendAsync("Receive", message, senderId); + //await Clients.Group(request.RecipientId.ToString()).SendAsync("Receive", message, senderId); + // Clients.Group(senderId.ToString()).SendAsync("Receive", message, senderId); } catch (Exception ex) { - _logger.LogError(ex, "Error sending message from {SenderId} to {RecipientId}", senderId, toUserId); + //_logger.LogError(ex, "Error sending message from {SenderId} to {RecipientId}", senderId, toUserId); throw; } } diff --git a/Govor.Application/Interfaces/AdminsStuff/UsersService.cs b/Govor.Application/Interfaces/AdminsStuff/UsersService.cs index 10f41ec..56044cb 100644 --- a/Govor.Application/Interfaces/AdminsStuff/UsersService.cs +++ b/Govor.Application/Interfaces/AdminsStuff/UsersService.cs @@ -26,4 +26,11 @@ public class UsersService : IUsersAdministration return new List(); } } + + public async Task GetUserById(Guid userId) + { + var result = await _usersRepository.FindByIdAsync(userId); + + return result; + } } \ No newline at end of file diff --git a/Govor.Application/Interfaces/Authentication/IJwtService.cs b/Govor.Application/Interfaces/Authentication/IJwtService.cs index 96a0c15..7f0bf6b 100644 --- a/Govor.Application/Interfaces/Authentication/IJwtService.cs +++ b/Govor.Application/Interfaces/Authentication/IJwtService.cs @@ -4,5 +4,5 @@ namespace Govor.API.Services.Authentication.Interfaces; public interface IJwtService { - string GenerateJwtToken(User user); + Task GenerateJwtTokenAsync(User user); } \ No newline at end of file diff --git a/Govor.Application/Interfaces/IUsersAdministration.cs b/Govor.Application/Interfaces/IUsersAdministration.cs index f08836f..a6ccae5 100644 --- a/Govor.Application/Interfaces/IUsersAdministration.cs +++ b/Govor.Application/Interfaces/IUsersAdministration.cs @@ -5,4 +5,5 @@ namespace Govor.API.Services.AdminsStuff.Interfaces; public interface IUsersAdministration { Task> GetAllUsersAsync(); + Task GetUserById(Guid userId); } \ No newline at end of file diff --git a/Govor.Application/Services/AuthService.cs b/Govor.Application/Services/AuthService.cs index 8b2d0b1..e5682c2 100644 --- a/Govor.Application/Services/AuthService.cs +++ b/Govor.Application/Services/AuthService.cs @@ -57,7 +57,7 @@ public class AuthService : IAccountService SetRole(user, invitation); - return _jwtService.GenerateJwtToken(user); + return await _jwtService.GenerateJwtTokenAsync(user); } @@ -71,7 +71,7 @@ public class AuthService : IAccountService if (_passwordHasher.Verify(password, user.PasswordHash) == false) throw new LoginUserException(); - return _jwtService.GenerateJwtToken(user); + return await _jwtService.GenerateJwtTokenAsync(user); } private async void SetRole(User user, Invitation invitation) diff --git a/Govor.Application/Services/JwtService.cs b/Govor.Application/Services/JwtService.cs index bf0c37c..dcd8334 100644 --- a/Govor.Application/Services/JwtService.cs +++ b/Govor.Application/Services/JwtService.cs @@ -19,12 +19,12 @@ public class JwtService : IJwtService _invitesService = invitesService; } - public string GenerateJwtToken(User user) + public async Task GenerateJwtTokenAsync(User user) { var claims = new[] { new Claim("userId", user.Id.ToString()), - new Claim(ClaimTypes.Role, _invitesService.GetRoleAsync(user).Result, ClaimValueTypes.String) + new Claim(ClaimTypes.Role, await _invitesService.GetRoleAsync(user), ClaimValueTypes.String) }; var singing = new SigningCredentials( diff --git a/Govor.Contracts/Requests/MediaUploadRequest.cs b/Govor.Contracts/Requests/MediaUploadRequest.cs new file mode 100644 index 0000000..73f2c20 --- /dev/null +++ b/Govor.Contracts/Requests/MediaUploadRequest.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; +using Govor.Core.Models; + +namespace Govor.Contracts.Requests; + +public class MediaUploadRequest +{ + public byte[] Data { get; set; } + public string FileName { get; set; } + public string EncryptedKey { get; set; } = string.Empty; + public MediaType Type { get; set; } + public string MimeType { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/Govor.Contracts/Requests/SignalR/MediaReference.cs b/Govor.Contracts/Requests/SignalR/MediaReference.cs new file mode 100644 index 0000000..f5faef9 --- /dev/null +++ b/Govor.Contracts/Requests/SignalR/MediaReference.cs @@ -0,0 +1,11 @@ +using Govor.Core.Models; + +namespace Govor.Contracts.Requests.SignalR; + +public record MediaReference +{ + public Guid MediaId { get; init; } + public string EncryptedKey { get; init; } = string.Empty; + public MediaType Type { get; init; } + public string MimeType { get; init; } = string.Empty; +} \ No newline at end of file diff --git a/Govor.Contracts/Requests/SignalR/MessageRequest.cs b/Govor.Contracts/Requests/SignalR/MessageRequest.cs new file mode 100644 index 0000000..477b68c --- /dev/null +++ b/Govor.Contracts/Requests/SignalR/MessageRequest.cs @@ -0,0 +1,9 @@ +namespace Govor.Contracts.Requests.SignalR; + +public record MessageRequest +{ + public Guid RecipientId { get; init; } + public string EncryptedContent { get; init; } = string.Empty; + public Guid? ReplyToMessageId { get; set; } + public List MediaAttachments { get; set; } = new(); +} \ No newline at end of file diff --git a/Govor.Core/Models/MediaAttachments.cs b/Govor.Core/Models/MediaAttachments.cs index bc5b42c..43da672 100644 --- a/Govor.Core/Models/MediaAttachments.cs +++ b/Govor.Core/Models/MediaAttachments.cs @@ -4,11 +4,9 @@ public class MediaAttachments { public Guid Id { get; set; } public Guid MessageId { get; set; } - public MediaType Type { get; set; } public string FilePath { get; set; } = string.Empty; // local path in filesystem public string MimeType { get; set; } = string.Empty; - public string? EncryptedKey { get; set; } public Message Message { get; set; } = null!; From 565d3249e5745886f4cece6488466ae12bb2687e Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Wed, 2 Jul 2025 19:16:49 +0700 Subject: [PATCH 021/101] Add online pinging and friendship verification features Introduces OnlinePingingController and related integration/unit tests for user online status updates. Adds PingHandlerService with memory cache throttling, IPingHandlerService interface, and service registration. Implements VerifyFriendship service and interface for friendship validation, with exception handling. Refactors CurrentUserService for improved user ID extraction and testability. Updates ChatsHub to verify friendship before messaging. Cleans up MediaController and optimizes UsersRepository queries by removing unnecessary includes. --- .../OnlinePingingControllerTests.cs | 131 ++++++++++++++++++ .../Services/CurrentUserServiceTests.cs | 106 ++++++++++++++ .../Services/PingHandlerServiceTests.cs | 58 ++++++++ Govor.API/Controllers/MediaController.cs | 9 -- .../Controllers/OnlinePingingController.cs | 57 ++++++++ .../ConfigurationProgramExtensions.cs | 3 + Govor.API/Hubs/ChatsHub.cs | 13 +- .../VerifyFriendship/FriendshipException.cs | 9 ++ .../Extensions/CurrentUserService.cs | 16 ++- .../Interfaces/IPingHandlerService.cs | 6 + .../Interfaces/IVerifyFriendship.cs | 7 + .../Services/PingHandlerService.cs | 34 +++++ .../Services/VerifierFriendship.cs | 55 ++++++++ Govor.Data/Repositories/UsersRepository.cs | 11 -- 14 files changed, 483 insertions(+), 32 deletions(-) create mode 100644 Govor.API.Tests/IntegrationTests/Controllers/OnlinePingingControllerTests.cs create mode 100644 Govor.API.Tests/UnitTests/Services/CurrentUserServiceTests.cs create mode 100644 Govor.API.Tests/UnitTests/Services/PingHandlerServiceTests.cs create mode 100644 Govor.API/Controllers/OnlinePingingController.cs create mode 100644 Govor.Application/Exceptions/VerifyFriendship/FriendshipException.cs create mode 100644 Govor.Application/Interfaces/IPingHandlerService.cs create mode 100644 Govor.Application/Interfaces/IVerifyFriendship.cs create mode 100644 Govor.Application/Services/PingHandlerService.cs create mode 100644 Govor.Application/Services/VerifierFriendship.cs diff --git a/Govor.API.Tests/IntegrationTests/Controllers/OnlinePingingControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/OnlinePingingControllerTests.cs new file mode 100644 index 0000000..e68ae43 --- /dev/null +++ b/Govor.API.Tests/IntegrationTests/Controllers/OnlinePingingControllerTests.cs @@ -0,0 +1,131 @@ +using Govor.API.Controllers; +using Govor.Application.Interfaces; +using Govor.Application.Interfaces.Infrastructure.Extensions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Govor.API.Tests.IntegrationTests.Controllers; + +[TestFixture] +public class OnlinePingingControllerTests +{ + private Mock> _loggerMock; + private Mock _pingHandlerServiceMock; + private Mock _currentUserServiceMock; + private OnlinePingingController _controller; + + [SetUp] + public void SetUp() + { + _loggerMock = new Mock>(); + _pingHandlerServiceMock = new Mock(); + _currentUserServiceMock = new Mock(); + _controller = new OnlinePingingController(_loggerMock.Object, _pingHandlerServiceMock.Object, + _currentUserServiceMock.Object); + } + + [Test] + public async Task Ping_ValidUserId_ReturnsOk() + { + // Arrange + var userId = Guid.NewGuid(); + _currentUserServiceMock.Setup(x => x.GetCurrentUserId()).Returns(userId); + _pingHandlerServiceMock.Setup(x => x.Ping(userId)).Returns(Task.CompletedTask); + + // Act + var result = await _controller.Ping(); + + // Assert + Assert.That(result, Is.InstanceOf()); + _pingHandlerServiceMock.Verify(x => x.Ping(userId), Times.Once()); + _loggerMock.VerifyLog(LogLevel.Information, $"Ping from user {userId} processed successfully", Times.Once()); + } + + [Test] + public async Task Ping_InvalidOperationException_ReturnsBadRequest() + { + // Arrange + var userId = Guid.NewGuid(); + var exception = new InvalidOperationException("User not found"); + _currentUserServiceMock.Setup(x => x.GetCurrentUserId()).Returns(userId); + _pingHandlerServiceMock.Setup(x => x.Ping(userId)).ThrowsAsync(exception); + + // Act + var result = await _controller.Ping(); + + // Assert + Assert.That(result, Is.InstanceOf()); + var badRequestResult = (BadRequestObjectResult)result; + Assert.That(badRequestResult.Value, Is.EqualTo("User can't be found in our database.")); + _loggerMock.VerifyLog(LogLevel.Error, exception.Message, Times.Once()); + } + + [Test] + public async Task Ping_UnauthorizedAccessException_ReturnsForbid() + { + // Arrange + var userId = Guid.NewGuid(); + var exception = new UnauthorizedAccessException("Unauthorized"); + _currentUserServiceMock.Setup(x => x.GetCurrentUserId()).Returns(userId); + _pingHandlerServiceMock.Setup(x => x.Ping(userId)).ThrowsAsync(exception); + + // Act + var result = await _controller.Ping(); + + // Assert + Assert.That(result, Is.InstanceOf()); + _loggerMock.VerifyLog(LogLevel.Error, exception.Message, Times.Once()); + } + + [Test] + public async Task Ping_GeneralException_ReturnsStatusCode500() + { + // Arrange + var userId = Guid.NewGuid(); + var exception = new Exception("Unexpected error"); + _currentUserServiceMock.Setup(x => x.GetCurrentUserId()).Returns(userId); + _pingHandlerServiceMock.Setup(x => x.Ping(userId)).ThrowsAsync(exception); + + // Act + var result = await _controller.Ping(); + + // Assert + Assert.That(result, Is.InstanceOf()); + var objectResult = (ObjectResult)result; + Assert.That(objectResult.StatusCode, Is.EqualTo(500)); + _loggerMock.VerifyLog(LogLevel.Error, exception.Message, Times.Once()); + } + + [Test] + public async Task Ping_UnauthorizedUserId_ThrowsUnauthorizedAccessException() + { + // Arrange + var exception = new UnauthorizedAccessException("userID claim is missing or invalid"); + _currentUserServiceMock.Setup(x => x.GetCurrentUserId()).Throws(exception); + + // Act + var result = await _controller.Ping(); + + // Assert + Assert.That(result, Is.InstanceOf()); + _loggerMock.VerifyLog(LogLevel.Error, exception.Message, Times.Once()); + _pingHandlerServiceMock.Verify(x => x.Ping(It.IsAny()), Times.Never()); + } +} + +// Helper extension for verifying logger calls +public static class LoggerMockExtensions +{ + public static void VerifyLog(this Mock> logger, LogLevel level, string message, Times times) + { + logger.Verify( + x => x.Log( + It.Is(l => l == level), + It.IsAny(), + It.Is((v, t) => v.ToString().Contains(message)), + It.IsAny(), + It.IsAny>()), + times); + } +} diff --git a/Govor.API.Tests/UnitTests/Services/CurrentUserServiceTests.cs b/Govor.API.Tests/UnitTests/Services/CurrentUserServiceTests.cs new file mode 100644 index 0000000..352339e --- /dev/null +++ b/Govor.API.Tests/UnitTests/Services/CurrentUserServiceTests.cs @@ -0,0 +1,106 @@ +using System.Security.Claims; +using Govor.Application.Infrastructure.Extensions; +using Microsoft.AspNetCore.Http; +using Moq; + +namespace Govor.API.Tests.UnitTests.Services; + +[TestFixture] +public class CurrentUserServiceTests +{ + private Mock _httpContextAccessorMock; + private CurrentUserService _currentUserService; + + [SetUp] + public void SetUp() + { + _httpContextAccessorMock = new Mock(); + _currentUserService = new CurrentUserService(_httpContextAccessorMock.Object); + } + + [Test] + public void GetCurrentUserId_ValidUserIdClaim_ReturnsGuid() + { + // Arrange + var userId = Guid.NewGuid(); + var claims = new[] { new Claim("userId", userId.ToString()) }; + var identity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(identity); + + var httpContextMock = new Mock(); + httpContextMock.Setup(x => x.User).Returns(principal); + + _httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContextMock.Object); + + // Act + var result = _currentUserService.GetCurrentUserId(); + + // Assert + Assert.That(result, Is.EqualTo(userId)); + } + + [Test] + public void GetCurrentUserId_NoHttpContext_ThrowsUnauthorizedAccessException() + { + // Arrange + _httpContextAccessorMock.Setup(x => x.HttpContext).Returns((HttpContext)null); + + // Act & Assert + var ex = Assert.Throws(() => _currentUserService.GetCurrentUserId()); + Assert.That(ex.Message, Is.EqualTo("userID claim is missing or invalid")); + } + + [Test] + public void GetCurrentUserId_NoUserIdClaim_ThrowsUnauthorizedAccessException() + { + // Arrange + var claims = new[] { new Claim("otherClaim", "value") }; + var identity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(identity); + + var httpContextMock = new Mock(); + httpContextMock.Setup(x => x.User).Returns(principal); + + _httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContextMock.Object); + + // Act & Assert + var ex = Assert.Throws(() => _currentUserService.GetCurrentUserId()); + Assert.That(ex.Message, Is.EqualTo("userID claim is missing or invalid")); + } + + [Test] + public void GetCurrentUserId_InvalidUserIdClaim_ThrowsUnauthorizedAccessException() + { + // Arrange + var claims = new[] { new Claim("userId", "invalid-guid") }; + var identity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(identity); + + var httpContextMock = new Mock(); + httpContextMock.Setup(x => x.User).Returns(principal); + + _httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContextMock.Object); + + // Act & Assert + var ex = Assert.Throws(() => _currentUserService.GetCurrentUserId()); + Assert.That(ex.Message, Is.EqualTo("userID claim is missing or invalid")); + } + + [Test] + public void GetCurrentUserId_EmptyUserIdClaim_ThrowsUnauthorizedAccessException() + { + // Arrange + var claims = new[] { new Claim("userId", "") }; + var identity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(identity); + + var httpContextMock = new Mock(); + httpContextMock.Setup(x => x.User).Returns(principal); + + _httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContextMock.Object); + + // Act & Assert + var ex = Assert.Throws(() => _currentUserService.GetCurrentUserId()); + Assert.That(ex.Message, Is.EqualTo("userID claim is missing or invalid")); + } +} \ No newline at end of file diff --git a/Govor.API.Tests/UnitTests/Services/PingHandlerServiceTests.cs b/Govor.API.Tests/UnitTests/Services/PingHandlerServiceTests.cs new file mode 100644 index 0000000..4510910 --- /dev/null +++ b/Govor.API.Tests/UnitTests/Services/PingHandlerServiceTests.cs @@ -0,0 +1,58 @@ +using Govor.Application.Services; +using Govor.Core.Models; +using Govor.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; + +namespace Govor.Tests.Application.Services; + +[TestFixture] +public class PingHandlerServiceTests +{ + private GovorDbContext _dbContext = null!; + private IMemoryCache _memoryCache = null!; + private PingHandlerService _service = null!; + private Guid _userId; + + [SetUp] + public void SetUp() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + _dbContext = new GovorDbContext(options); + _memoryCache = new MemoryCache(new MemoryCacheOptions()); + _service = new PingHandlerService(_dbContext, _memoryCache); + + _userId = Guid.NewGuid(); + _dbContext.Users.Add(new User + { + Id = _userId, + Username = "TestUser", + WasOnline = DateTime.UtcNow.AddHours(-1), + Description = "Test description", + PasswordHash = "hashed_password_here" + }); + _dbContext.SaveChanges(); + } + + [Test] + public async Task Ping_DoesNotUpdate_WhenPingTooRecent() + { + // Arrange + var initial = DateTime.UtcNow.AddMinutes(-1); + _memoryCache.Set($"LastPing_{_userId}", DateTime.UtcNow); + + var user = await _dbContext.Users.FirstAsync(u => u.Id == _userId); + var originalTime = user.WasOnline; + + // Act + await _service.Ping(_userId); + + var updatedUser = await _dbContext.Users.FirstAsync(u => u.Id == _userId); + + // Assert + Assert.That(updatedUser.WasOnline, Is.EqualTo(originalTime)); + } +} diff --git a/Govor.API/Controllers/MediaController.cs b/Govor.API/Controllers/MediaController.cs index 947c9cf..4aece1b 100644 --- a/Govor.API/Controllers/MediaController.cs +++ b/Govor.API/Controllers/MediaController.cs @@ -14,7 +14,6 @@ public class MediaController : Controller { private readonly ILogger _logger; private readonly IStorageService _storageService; - private readonly IMediaAttachmentsRepository _repository; public MediaController(ILogger logger, IStorageService storageService) { @@ -31,14 +30,6 @@ public class MediaController : Controller var url = await _storageService.SaveAsync(request.Data,request.FileName); var mediaId = Guid.NewGuid(); - _repository.AddAsync(new MediaAttachments() - { - Id = mediaId, - FilePath = url, - EncryptedKey = request.EncryptedKey, - MimeType = request.MimeType, - Type = request.Type, - }); return Ok(mediaId); } diff --git a/Govor.API/Controllers/OnlinePingingController.cs b/Govor.API/Controllers/OnlinePingingController.cs new file mode 100644 index 0000000..71d74d7 --- /dev/null +++ b/Govor.API/Controllers/OnlinePingingController.cs @@ -0,0 +1,57 @@ +using Govor.Application.Interfaces; +using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.Core.Repositories.Users; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Govor.API.Controllers; + +[ApiController] +[Route("api/online")] +[Authorize(Roles = "User,Admin")] +public class OnlinePingingController : Controller +{ + private readonly ILogger _logger; + private readonly IPingHandlerService _ping; + private readonly ICurrentUserService _currentUserService; + + public OnlinePingingController(ILogger logger, + IPingHandlerService ping, + ICurrentUserService currentUserService) + { + _logger = logger; + _ping = ping; + _currentUserService = currentUserService; + } + + + [HttpPatch("ping")]// api/online/ping + public async Task Ping() + { + try + { + _logger.LogInformation("Ping..."); + + var id = _currentUserService.GetCurrentUserId(); + await _ping.Ping(id); + + _logger.LogInformation($"Ping from user {id} processed successfully"); + return Ok(); + } + catch (InvalidOperationException e) + { + _logger.LogError(e, e.Message); + return BadRequest("User can't be found in our database."); + } + catch (UnauthorizedAccessException e) + { + _logger.LogError(e, e.Message); + return Forbid(e.Message); + } + catch (Exception e) + { + _logger.LogError(e, e.Message); + return StatusCode(500, new { error = "Failed to send friend request." }); + } + } +} \ No newline at end of file diff --git a/Govor.API/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Extensions/ConfigurationProgramExtensions.cs index b09764b..15281fe 100644 --- a/Govor.API/Extensions/ConfigurationProgramExtensions.cs +++ b/Govor.API/Extensions/ConfigurationProgramExtensions.cs @@ -43,6 +43,9 @@ public static class ConfigurationProgramExtensions services.AddHttpContextAccessor(); // it's very important for CurrentUserService services.AddScoped(); + + services.AddMemoryCache(); + services.AddScoped(); } public static void AddRepositories(this IServiceCollection services) diff --git a/Govor.API/Hubs/ChatsHub.cs b/Govor.API/Hubs/ChatsHub.cs index e0ffe2d..4942226 100644 --- a/Govor.API/Hubs/ChatsHub.cs +++ b/Govor.API/Hubs/ChatsHub.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using Govor.Application.Interfaces; using Govor.Contracts.Requests.SignalR; using Govor.Core.Models; using Govor.Core.Repositories.Users; @@ -13,6 +14,7 @@ namespace Govor.API.Hubs; public class ChatsHub : Hub { private readonly IUsersRepository _usersRepository; + private readonly IVerifyFriendship _verifyFriendship; private readonly ILogger _logger; public ChatsHub(IUsersRepository usersRepository, ILogger logger) @@ -60,21 +62,22 @@ public class ChatsHub : Hub var senderId = GetUserId(); - // Проверка существования получателя - /*try + // Проверка существования получателя и установленной дружбы + try { - await _usersRepository.FindByIdAsync(toUserId); + await _usersRepository.FindByIdAsync(request.RecipientId); + await _verifyFriendship.VerifyAsync(senderId, request.RecipientId); } catch (NotFoundByKeyException ex) { - _logger.LogWarning("Recipient user {ToUserId} not found", toUserId); + _logger.LogWarning("Recipient user {ToUserId} not found", request.RecipientId); throw; } catch (ArgumentException ex) { _logger.LogWarning("Invalid recipient userId received from user {UserId}", GetUserId()); throw; - }*/ + } try { diff --git a/Govor.Application/Exceptions/VerifyFriendship/FriendshipException.cs b/Govor.Application/Exceptions/VerifyFriendship/FriendshipException.cs new file mode 100644 index 0000000..ab56926 --- /dev/null +++ b/Govor.Application/Exceptions/VerifyFriendship/FriendshipException.cs @@ -0,0 +1,9 @@ +using Govor.Core; + +namespace Govor.Application.Exceptions.VerifyFriendship; + +public class FriendshipException : GovorCoreException +{ + public FriendshipException(string s) + :base(s) { } +} \ No newline at end of file diff --git a/Govor.Application/Infrastructure/Extensions/CurrentUserService.cs b/Govor.Application/Infrastructure/Extensions/CurrentUserService.cs index 3cb4b67..dfe4732 100644 --- a/Govor.Application/Infrastructure/Extensions/CurrentUserService.cs +++ b/Govor.Application/Infrastructure/Extensions/CurrentUserService.cs @@ -6,21 +6,23 @@ namespace Govor.Application.Infrastructure.Extensions; public class CurrentUserService : ICurrentUserService { - private readonly ClaimsPrincipal _user; - + private readonly IHttpContextAccessor _httpContextAccessor; + public CurrentUserService(IHttpContextAccessor httpContextAccessor) { - _user = httpContextAccessor.HttpContext.User; + _httpContextAccessor = httpContextAccessor; } - + public Guid GetCurrentUserId() { - var userIdClaim = _user.FindFirst("userId")?.Value; - + var user = _httpContextAccessor.HttpContext?.User; + var userIdClaim = user?.FindFirst("userId")?.Value; + if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) { throw new UnauthorizedAccessException("userID claim is missing or invalid"); } + return userId; } -} \ No newline at end of file +} diff --git a/Govor.Application/Interfaces/IPingHandlerService.cs b/Govor.Application/Interfaces/IPingHandlerService.cs new file mode 100644 index 0000000..fe3fa0f --- /dev/null +++ b/Govor.Application/Interfaces/IPingHandlerService.cs @@ -0,0 +1,6 @@ +namespace Govor.Application.Interfaces; + +public interface IPingHandlerService +{ + Task Ping(Guid userId); +} \ No newline at end of file diff --git a/Govor.Application/Interfaces/IVerifyFriendship.cs b/Govor.Application/Interfaces/IVerifyFriendship.cs new file mode 100644 index 0000000..27fb6bb --- /dev/null +++ b/Govor.Application/Interfaces/IVerifyFriendship.cs @@ -0,0 +1,7 @@ +namespace Govor.Application.Interfaces; + +public interface IVerifyFriendship +{ + Task VerifyAsync(Guid targetUserId, Guid friendUserId); + Task TryVerifyAsync(Guid targetUserId, Guid friendUserId); +} \ No newline at end of file diff --git a/Govor.Application/Services/PingHandlerService.cs b/Govor.Application/Services/PingHandlerService.cs new file mode 100644 index 0000000..1cc0e37 --- /dev/null +++ b/Govor.Application/Services/PingHandlerService.cs @@ -0,0 +1,34 @@ +using Govor.Application.Interfaces; +using Govor.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; + +namespace Govor.Application.Services; + +public class PingHandlerService : IPingHandlerService +{ + private readonly GovorDbContext _context; + private readonly IMemoryCache _cache; + + public PingHandlerService(GovorDbContext context, IMemoryCache cache) + { + _context = context; + _cache = cache; + } + + public async Task Ping(Guid userId) + { + var cacheKey = $"LastPing_{userId}"; + if (_cache.TryGetValue(cacheKey, out DateTime lastPing) && + DateTime.UtcNow.Subtract(lastPing).TotalSeconds < 30) + { + return; // Пропускаем слишком частые пинги + } + + await _context.Users + .Where(u => u.Id == userId) + .ExecuteUpdateAsync(u => u.SetProperty(x => x.WasOnline, DateTime.UtcNow)); + + _cache.Set(cacheKey, DateTime.UtcNow, TimeSpan.FromSeconds(30)); + } +} \ No newline at end of file diff --git a/Govor.Application/Services/VerifierFriendship.cs b/Govor.Application/Services/VerifierFriendship.cs new file mode 100644 index 0000000..9729110 --- /dev/null +++ b/Govor.Application/Services/VerifierFriendship.cs @@ -0,0 +1,55 @@ +using Govor.Application.Exceptions.VerifyFriendship; +using Govor.Application.Interfaces; +using Govor.Core.Models; +using Govor.Core.Repositories.Friendships; +using Microsoft.Extensions.Logging; + +namespace Govor.Application.Services; + + +public class VerifyFriendship : IVerifyFriendship +{ + private readonly IFriendshipsRepository _friendshipsRepository; + private readonly ILogger _logger; + private const string FriendshipNotAcceptedError = "Friendship between user {0} and friend {1} does not exist or is not accepted."; + + public VerifyFriendship(IFriendshipsRepository friendshipsRepository, ILogger logger = null) + { + _friendshipsRepository = friendshipsRepository ?? throw new ArgumentNullException(nameof(friendshipsRepository)); + _logger = logger; + } + + public async Task VerifyAsync(Guid targetUserId, Guid friendUserId) + { + if (targetUserId == Guid.Empty || friendUserId == Guid.Empty) + { + _logger?.LogWarning("Invalid user IDs provided: targetUserId={TargetUserId}, friendUserId={FriendUserId}", targetUserId, friendUserId); + throw new ArgumentException("User IDs cannot be empty.", nameof(targetUserId)); + } + + var friendships = await _friendshipsRepository.FindByUserIdAsync(targetUserId); + var friendship = friendships.Where(f => f.AddresseeId == friendUserId || f.RequesterId == friendUserId)?.FirstOrDefault(); + + if (friendship == null || friendship.Status != FriendshipStatus.Accepted) + { + var errorMessage = string.Format(FriendshipNotAcceptedError, targetUserId, friendUserId); + _logger?.LogError(errorMessage); + throw new FriendshipException(errorMessage); + } + + _logger?.LogInformation("Friendship verified successfully for targetUserId={TargetUserId}, friendUserId={FriendUserId}", targetUserId, friendUserId); + } + + public async Task TryVerifyAsync(Guid targetUserId, Guid friendUserId) + { + try + { + await VerifyAsync(targetUserId, friendUserId); + return true; + } + catch (FriendshipException ex) + { + return false; + } + } +} \ No newline at end of file diff --git a/Govor.Data/Repositories/UsersRepository.cs b/Govor.Data/Repositories/UsersRepository.cs index c9822aa..3cd8dfb 100644 --- a/Govor.Data/Repositories/UsersRepository.cs +++ b/Govor.Data/Repositories/UsersRepository.cs @@ -22,10 +22,6 @@ public class UsersRepository : IUsersRepository { return await _context.Users .AsNoTracking() - .Include(u => u.Invite) - .Include(u => u.ReceivedFriendRequests) - .Include(u => u.SentFriendRequests) - .AsSplitQuery() .ToListOrThrowIfEmpty(new NotFoundException("Users in Database not exists")); } @@ -52,10 +48,6 @@ public class UsersRepository : IUsersRepository return await _context.Users .AsNoTracking() .Where(x => ids.Contains(x.Id)) - .Include(u => u.Invite) - .Include(u => u.ReceivedFriendRequests) - .Include(u => u.SentFriendRequests) - .AsSplitQuery() .ToListOrThrowIfEmpty(new NotFoundByKeyException>(ids,"Users with given ids not found")); } @@ -78,9 +70,6 @@ public class UsersRepository : IUsersRepository { return await _context.Users .AsNoTracking() - .Include(u => u.Invite) - .Include(u => u.ReceivedFriendRequests) - .Include(u => u.SentFriendRequests) .AsSplitQuery() .Where(u => u.Id != currentUserId && u.Username.ToLower().Contains(query.ToLower()) && From d170c6270f506b1fb069b9f1238018292f04225d Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Thu, 3 Jul 2025 19:46:53 +0700 Subject: [PATCH 022/101] hot fix for friends controller --- .../Controllers/FriendsControllerTests.cs | 4 +- Govor.API/Controllers/FriendsController.cs | 10 +-- .../Controllers/OnlinePingingController.cs | 6 ++ Govor.API/Hubs/ChatsHub.cs | 75 +++++++++++-------- .../Interfaces/Messages/IChatService.cs | 8 ++ .../Messages/IMessageManagementService.cs | 9 +++ .../Messages/IMessageReactionService.cs | 6 ++ .../Messages/IMessageSendingService.cs | 8 ++ .../Interfaces/Messages/Parameters/Result.cs | 3 + .../Messages/Parameters/SendMedia.cs | 8 ++ .../Messages/Parameters/SendMessage.cs | 9 +++ Govor.Application/Services/FriendsService.cs | 7 +- .../Services/PrivateChatService.cs | 68 +++++++++++++++++ .../Requests/SignalR/MessageRequest.cs | 3 + .../Repositories/FriendshipsRepository.cs | 6 +- Govor.Data/Repositories/UsersRepository.cs | 12 ++- 16 files changed, 193 insertions(+), 49 deletions(-) create mode 100644 Govor.Application/Interfaces/Messages/IChatService.cs create mode 100644 Govor.Application/Interfaces/Messages/IMessageManagementService.cs create mode 100644 Govor.Application/Interfaces/Messages/IMessageReactionService.cs create mode 100644 Govor.Application/Interfaces/Messages/IMessageSendingService.cs create mode 100644 Govor.Application/Interfaces/Messages/Parameters/Result.cs create mode 100644 Govor.Application/Interfaces/Messages/Parameters/SendMedia.cs create mode 100644 Govor.Application/Interfaces/Messages/Parameters/SendMessage.cs create mode 100644 Govor.Application/Services/PrivateChatService.cs diff --git a/Govor.API.Tests/IntegrationTests/Controllers/FriendsControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/FriendsControllerTests.cs index 34d0791..98618d5 100644 --- a/Govor.API.Tests/IntegrationTests/Controllers/FriendsControllerTests.cs +++ b/Govor.API.Tests/IntegrationTests/Controllers/FriendsControllerTests.cs @@ -141,7 +141,7 @@ public class FriendsControllerTests _currentUserServiceMock.Setup(c => c.GetCurrentUserId()).Returns(currentUserId); - _friendsServiceMock.Setup(f => f.SendFriendRequestAsync(targetUserId, currentUserId)) + _friendsServiceMock.Setup(f => f.SendFriendRequestAsync(currentUserId, targetUserId)) .ThrowsAsync(new RequestAlreadySentException(currentUserId, targetUserId)); var result = await _controller.SendRequest(targetUserId); @@ -351,7 +351,7 @@ public class FriendsControllerTests var result = await _controller.GetFriends(); // Assert - Assert.That(result, Is.InstanceOf()); + Assert.That(result, Is.InstanceOf()); } [Test] diff --git a/Govor.API/Controllers/FriendsController.cs b/Govor.API/Controllers/FriendsController.cs index c7092fc..d49a732 100644 --- a/Govor.API/Controllers/FriendsController.cs +++ b/Govor.API/Controllers/FriendsController.cs @@ -57,7 +57,7 @@ public class FriendsController : Controller try { - await _friendsService.SendFriendRequestAsync(targetUserId, _currentUserService.GetCurrentUserId()); + await _friendsService.SendFriendRequestAsync(_currentUserService.GetCurrentUserId(), targetUserId); return Ok(new { message = "Friend request sent successfully." }); } catch (InvalidOperationException ex) @@ -98,14 +98,14 @@ public class FriendsController : Controller } [HttpPost("accept")] - public async Task AcceptFriend(Guid requesterId) + public async Task AcceptFriend(Guid friendshipId) { - if (requesterId == Guid.Empty) + if (friendshipId == Guid.Empty) return BadRequest("Requester ID is invalid"); try { - await _friendsService.AcceptFriendRequestAsync(requesterId, _currentUserService.GetCurrentUserId()); + await _friendsService.AcceptFriendRequestAsync(friendshipId, _currentUserService.GetCurrentUserId()); return Ok(new { message = "Friend request accepted." }); } catch (InvalidOperationException ex) @@ -136,7 +136,7 @@ public class FriendsController : Controller catch (InvalidOperationException ex) { _logger.LogError(ex, ex.Message); - return BadRequest(new { error = "User data not found." }); + return Ok(Array.Empty()); } catch (Exception ex) { diff --git a/Govor.API/Controllers/OnlinePingingController.cs b/Govor.API/Controllers/OnlinePingingController.cs index 71d74d7..871e960 100644 --- a/Govor.API/Controllers/OnlinePingingController.cs +++ b/Govor.API/Controllers/OnlinePingingController.cs @@ -54,4 +54,10 @@ public class OnlinePingingController : Controller return StatusCode(500, new { error = "Failed to send friend request." }); } } + + [HttpGet("is-online")] + public async Task IsOnline(Guid userId) + { + return BadRequest(); + } } \ No newline at end of file diff --git a/Govor.API/Hubs/ChatsHub.cs b/Govor.API/Hubs/ChatsHub.cs index 4942226..5c37ba9 100644 --- a/Govor.API/Hubs/ChatsHub.cs +++ b/Govor.API/Hubs/ChatsHub.cs @@ -1,11 +1,9 @@ -using System.Security.Claims; -using Govor.Application.Interfaces; +using Govor.API.Services; +using Govor.Application.Interfaces.Messages; +using Govor.Application.Interfaces.Messages.Parameters; using Govor.Contracts.Requests.SignalR; using Govor.Core.Models; -using Govor.Core.Repositories.Users; -using Govor.Data.Repositories.Exceptions; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; namespace Govor.API.Hubs; @@ -13,14 +11,16 @@ namespace Govor.API.Hubs; [Authorize] public class ChatsHub : Hub { - private readonly IUsersRepository _usersRepository; - private readonly IVerifyFriendship _verifyFriendship; private readonly ILogger _logger; - - public ChatsHub(IUsersRepository usersRepository, ILogger logger) + private readonly IChatService _chatService; + private readonly IGroupService _groupService; + + public ChatsHub(ILogger logger, + IChatService chatService + ) { - _usersRepository = usersRepository; _logger = logger; + _chatService = chatService; } public override async Task OnConnectedAsync() @@ -28,7 +28,7 @@ public class ChatsHub : Hub var userId = GetUserId(); if (userId != Guid.Empty) { - // Привязываем ConnectionId к UserId + // Binding ConnectionId to UserId await Groups.AddToGroupAsync(Context.ConnectionId, userId.ToString()); _logger.LogInformation("User {UserId} connected with ConnectionId {ConnectionId}", userId, Context.ConnectionId); } @@ -46,6 +46,11 @@ public class ChatsHub : Hub await base.OnDisconnectedAsync(exception); } + public async Task Remove(Guid recipientId, Guid messageId) + { + + } + public async Task Edit(string newMessage, Guid messageId) { @@ -53,7 +58,6 @@ public class ChatsHub : Hub public async Task Send(MessageRequest request) { - // Валидация входных данных if (string.IsNullOrWhiteSpace(request.EncryptedContent)) { _logger.LogWarning("Empty message received from user {UserId}", GetUserId()); @@ -62,38 +66,43 @@ public class ChatsHub : Hub var senderId = GetUserId(); - // Проверка существования получателя и установленной дружбы - try - { - await _usersRepository.FindByIdAsync(request.RecipientId); - await _verifyFriendship.VerifyAsync(senderId, request.RecipientId); - } - catch (NotFoundByKeyException ex) - { - _logger.LogWarning("Recipient user {ToUserId} not found", request.RecipientId); - throw; - } - catch (ArgumentException ex) - { - _logger.LogWarning("Invalid recipient userId received from user {UserId}", GetUserId()); - throw; - } - try { _logger.LogInformation("Message sent from {SenderId} to {RecipientId} at {UtcNow}", senderId, request.RecipientId, DateTime.UtcNow); + + var message = new SendMessage( + EncryptContent: request.EncryptedContent, + ReplyToMessageId: request.ReplyToMessageId, + FromUserId: senderId, + RecipientId: request.RecipientId, + SendAt: DateTime.UtcNow, + Media: request.MediaAttachments?.Select(f => new SendMedia( + f.MediaId, f.EncryptedKey, f.Type, f.MimeType)) ?? Array.Empty()); - // Отправка сообщения отправителю и получателю - //await Clients.Group(request.RecipientId.ToString()).SendAsync("Receive", message, senderId); - // Clients.Group(senderId.ToString()).SendAsync("Receive", message, senderId); + if (request.RecipientType == RecipientType.User) + { + await SendUser(message); + } + // TODO: Send to Group } catch (Exception ex) { - //_logger.LogError(ex, "Error sending message from {SenderId} to {RecipientId}", senderId, toUserId); + _logger.LogError(ex, "Error sending message from {SenderId} to {RecipientId}", senderId, request.RecipientId); throw; } } + private async Task SendUser(SendMessage sendMessage) + { + Result result = await _chatService.SendMessageAsync(sendMessage); + if(result.IsSuccess == false) + throw result.Exception; + + // Sending a message to the sender and recipient + await Clients.Group(sendMessage.RecipientId.ToString()).SendAsync("Receive", sendMessage); + await Clients.Group(sendMessage.FromUserId.ToString()).SendAsync("Receive", sendMessage); + } + private Guid GetUserId() { var userIdClaim = Context.User?.FindFirst("userID")?.Value; diff --git a/Govor.Application/Interfaces/Messages/IChatService.cs b/Govor.Application/Interfaces/Messages/IChatService.cs new file mode 100644 index 0000000..e44dc9b --- /dev/null +++ b/Govor.Application/Interfaces/Messages/IChatService.cs @@ -0,0 +1,8 @@ +namespace Govor.Application.Interfaces.Messages; + +public interface IChatService : IMessageSendingService, IMessageManagementService +{ + +} + + diff --git a/Govor.Application/Interfaces/Messages/IMessageManagementService.cs b/Govor.Application/Interfaces/Messages/IMessageManagementService.cs new file mode 100644 index 0000000..9b35c43 --- /dev/null +++ b/Govor.Application/Interfaces/Messages/IMessageManagementService.cs @@ -0,0 +1,9 @@ +using Govor.Application.Interfaces.Messages.Parameters; + +namespace Govor.Application.Interfaces.Messages; + +public interface IMessageManagementService +{ + Task EditMessageAsync(Guid editorId, Guid messageId, string newContent); + Task DeleteMessageAsync(Guid editorId, Guid messageId); +} \ No newline at end of file diff --git a/Govor.Application/Interfaces/Messages/IMessageReactionService.cs b/Govor.Application/Interfaces/Messages/IMessageReactionService.cs new file mode 100644 index 0000000..c8b624a --- /dev/null +++ b/Govor.Application/Interfaces/Messages/IMessageReactionService.cs @@ -0,0 +1,6 @@ +namespace Govor.Application.Interfaces.Messages; + +public interface IMessageReactionService +{ + +} \ No newline at end of file diff --git a/Govor.Application/Interfaces/Messages/IMessageSendingService.cs b/Govor.Application/Interfaces/Messages/IMessageSendingService.cs new file mode 100644 index 0000000..1bee95f --- /dev/null +++ b/Govor.Application/Interfaces/Messages/IMessageSendingService.cs @@ -0,0 +1,8 @@ +using Govor.Application.Interfaces.Messages.Parameters; + +namespace Govor.Application.Interfaces.Messages; + +public interface IMessageSendingService +{ + Task SendMessageAsync(SendMessage newMessage); +} \ No newline at end of file diff --git a/Govor.Application/Interfaces/Messages/Parameters/Result.cs b/Govor.Application/Interfaces/Messages/Parameters/Result.cs new file mode 100644 index 0000000..c07618e --- /dev/null +++ b/Govor.Application/Interfaces/Messages/Parameters/Result.cs @@ -0,0 +1,3 @@ +namespace Govor.Application.Interfaces.Messages.Parameters; + +public record Result(bool IsSuccess, Exception Exception); \ No newline at end of file diff --git a/Govor.Application/Interfaces/Messages/Parameters/SendMedia.cs b/Govor.Application/Interfaces/Messages/Parameters/SendMedia.cs new file mode 100644 index 0000000..06a002d --- /dev/null +++ b/Govor.Application/Interfaces/Messages/Parameters/SendMedia.cs @@ -0,0 +1,8 @@ +using Govor.Core.Models; + +namespace Govor.Application.Interfaces.Messages.Parameters; + +public record SendMedia(Guid Id, + string EncryptedKey, + MediaType Type, + string MimeType); \ No newline at end of file diff --git a/Govor.Application/Interfaces/Messages/Parameters/SendMessage.cs b/Govor.Application/Interfaces/Messages/Parameters/SendMessage.cs new file mode 100644 index 0000000..23c7cc8 --- /dev/null +++ b/Govor.Application/Interfaces/Messages/Parameters/SendMessage.cs @@ -0,0 +1,9 @@ +namespace Govor.Application.Interfaces.Messages.Parameters; + +public record SendMessage( + string EncryptContent, + Guid? ReplyToMessageId, + Guid RecipientId, + Guid FromUserId, + DateTime SendAt, + IEnumerable Media); \ No newline at end of file diff --git a/Govor.Application/Services/FriendsService.cs b/Govor.Application/Services/FriendsService.cs index b906207..c68f2bb 100644 --- a/Govor.Application/Services/FriendsService.cs +++ b/Govor.Application/Services/FriendsService.cs @@ -26,12 +26,8 @@ public class FriendsService : IFriendsService { all = await _usersRepository.SearchPotentialFriendsAsync(currentId, query); - var friends = await _friendshipsRepository.FindByUserIdAsync(currentId); - - friends = friends.Where(f => f.Status == FriendshipStatus.Accepted).ToList(); - return all - .Where(u => u.Id != currentId && !friends.Select(f => f.RequesterId).Contains(u.Id)) + .Where(u => u.Id != currentId) .ToList(); } catch (NotFoundByKeyException<(string, Guid)> ex) @@ -94,7 +90,6 @@ public class FriendsService : IFriendsService var friendships = await _friendshipsRepository.FindByUserIdAsync(userId); return friendships - .Where(f => f.Status == FriendshipStatus.Accepted) .Select(f => f.RequesterId == userId ? f.Addressee : f.Requester) .ToList(); } diff --git a/Govor.Application/Services/PrivateChatService.cs b/Govor.Application/Services/PrivateChatService.cs new file mode 100644 index 0000000..f18dad5 --- /dev/null +++ b/Govor.Application/Services/PrivateChatService.cs @@ -0,0 +1,68 @@ +using Govor.Application.Interfaces; +using Govor.Application.Interfaces.Messages; +using Govor.Application.Interfaces.Messages.Parameters; +using Govor.Core.Models; +using Govor.Core.Repositories.Messages; +using Govor.Data; +using Microsoft.EntityFrameworkCore; + +namespace Govor.Application.Services; + +public class PrivateChatService : IChatService +{ + private readonly IVerifyFriendship _verifyFriendship; + private readonly IMessagesRepository _messages; + + public PrivateChatService(IMessagesRepository messages, IVerifyFriendship verifyFriendship) + { + _messages = messages; + _verifyFriendship = verifyFriendship; + } + + public async Task SendMessageAsync(SendMessage newMessage) + { + try + { + await _verifyFriendship.VerifyAsync(newMessage.FromUserId, newMessage.RecipientId); + + var messageId = Guid.NewGuid(); + + var message = new Message() + { + Id = messageId, + EncryptedContent = newMessage.EncryptContent, + IsEdited = false, + SenderId = newMessage.FromUserId, + RecipientId = newMessage.RecipientId, + RecipientType = RecipientType.User, + ReplyToMessageId = newMessage.ReplyToMessageId, + + MediaAttachments = newMessage.Media.Select(m => new MediaAttachments() + { + Id = m.Id, + MessageId = messageId, + Type = m.Type, + MimeType = m.MimeType, + EncryptedKey = m.EncryptedKey, + }).ToList() + }; + + await _messages.AddAsync(message); + return new Result(true, null); + } + catch (Exception ex) + { + return new Result(false, ex); + } + } + + public Task EditMessageAsync(Guid editorId, Guid messageId, string newContent) + { + throw new NotImplementedException(); + } + + public Task DeleteMessageAsync(Guid editorId, Guid messageId) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/Govor.Contracts/Requests/SignalR/MessageRequest.cs b/Govor.Contracts/Requests/SignalR/MessageRequest.cs index 477b68c..ec1417f 100644 --- a/Govor.Contracts/Requests/SignalR/MessageRequest.cs +++ b/Govor.Contracts/Requests/SignalR/MessageRequest.cs @@ -1,8 +1,11 @@ +using Govor.Core.Models; + namespace Govor.Contracts.Requests.SignalR; public record MessageRequest { public Guid RecipientId { get; init; } + public RecipientType RecipientType { get; set; } public string EncryptedContent { get; init; } = string.Empty; public Guid? ReplyToMessageId { get; set; } public List MediaAttachments { get; set; } = new(); diff --git a/Govor.Data/Repositories/FriendshipsRepository.cs b/Govor.Data/Repositories/FriendshipsRepository.cs index f2a0d7a..7ccf49b 100644 --- a/Govor.Data/Repositories/FriendshipsRepository.cs +++ b/Govor.Data/Repositories/FriendshipsRepository.cs @@ -46,7 +46,8 @@ public class FriendshipsRepository : IFriendshipsRepository .Include(x => x.Requester) .Include(x => x.Addressee) .AsSplitQuery() - .Where(x => x.RequesterId == userId) + .Where(f => + (f.RequesterId == userId || f.AddresseeId == userId)) .ToListOrThrowIfEmpty(new NotFoundByKeyException(userId, "Friendship with given user id was not found")); } @@ -113,6 +114,7 @@ public class FriendshipsRepository : IFriendshipsRepository public bool Exist(Guid requesterId, Guid addresseeId) { - return _context.Friendships.AsNoTracking().Any(x => (x.RequesterId == requesterId && x.AddresseeId == addresseeId)); + return _context.Friendships.AsNoTracking().Any(x => (x.RequesterId == requesterId && x.AddresseeId == addresseeId) || + x.RequesterId == addresseeId && x.AddresseeId == requesterId); } } \ No newline at end of file diff --git a/Govor.Data/Repositories/UsersRepository.cs b/Govor.Data/Repositories/UsersRepository.cs index 3cd8dfb..b08bbb9 100644 --- a/Govor.Data/Repositories/UsersRepository.cs +++ b/Govor.Data/Repositories/UsersRepository.cs @@ -22,6 +22,10 @@ public class UsersRepository : IUsersRepository { return await _context.Users .AsNoTracking() + .Include(u => u.Invite) + .Include(u => u.ReceivedFriendRequests) + .Include(u => u.SentFriendRequests) + .AsSplitQuery() .ToListOrThrowIfEmpty(new NotFoundException("Users in Database not exists")); } @@ -48,6 +52,10 @@ public class UsersRepository : IUsersRepository return await _context.Users .AsNoTracking() .Where(x => ids.Contains(x.Id)) + .Include(u => u.Invite) + .Include(u => u.ReceivedFriendRequests) + .Include(u => u.SentFriendRequests) + .AsSplitQuery() .ToListOrThrowIfEmpty(new NotFoundByKeyException>(ids,"Users with given ids not found")); } @@ -70,6 +78,9 @@ public class UsersRepository : IUsersRepository { return await _context.Users .AsNoTracking() + .Include(u => u.Invite) + .Include(u => u.ReceivedFriendRequests) + .Include(u => u.SentFriendRequests) .AsSplitQuery() .Where(u => u.Id != currentUserId && u.Username.ToLower().Contains(query.ToLower()) && @@ -211,5 +222,4 @@ public class UsersRepository : IUsersRepository { return _context.Users.AnyAsync(u => u.Username == username); } - } \ No newline at end of file From fab3d6b67bc24a8841930baec233709eec5c3fde Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Fri, 4 Jul 2025 12:43:12 +0700 Subject: [PATCH 023/101] ChatGroup work --- Govor.API/Hubs/ChatsHub.cs | 52 +++++++++++++------ Govor.Application/Interfaces/IGroupService.cs | 3 +- .../Interfaces/Messages/Parameters/Result.cs | 2 +- .../Services/PrivateChatService.cs | 5 +- .../Requests/SignalR/GroupMessageRequest.cs | 9 ++++ .../Requests/SignalR/MessageRequest.cs | 1 - .../Responses/SignalR/UserMessageResponse.cs | 12 +++++ Govor.Data/Repositories/MessagesRepository.cs | 2 +- 8 files changed, 65 insertions(+), 21 deletions(-) create mode 100644 Govor.Contracts/Requests/SignalR/GroupMessageRequest.cs create mode 100644 Govor.Contracts/Responses/SignalR/UserMessageResponse.cs diff --git a/Govor.API/Hubs/ChatsHub.cs b/Govor.API/Hubs/ChatsHub.cs index 5c37ba9..b61dba0 100644 --- a/Govor.API/Hubs/ChatsHub.cs +++ b/Govor.API/Hubs/ChatsHub.cs @@ -2,6 +2,7 @@ using Govor.API.Services; using Govor.Application.Interfaces.Messages; using Govor.Application.Interfaces.Messages.Parameters; using Govor.Contracts.Requests.SignalR; +using Govor.Contracts.Responses.SignalR; using Govor.Core.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.SignalR; @@ -26,12 +27,14 @@ public class ChatsHub : Hub public override async Task OnConnectedAsync() { var userId = GetUserId(); + if (userId != Guid.Empty) { // Binding ConnectionId to UserId await Groups.AddToGroupAsync(Context.ConnectionId, userId.ToString()); _logger.LogInformation("User {UserId} connected with ConnectionId {ConnectionId}", userId, Context.ConnectionId); } + await base.OnConnectedAsync(); } @@ -56,6 +59,33 @@ public class ChatsHub : Hub } + public async Task SendGroup(GroupMessageRequest request) + { + var senderId = GetUserId(); + + // Создание сообщения + var message = new SendMessage( + EncryptContent: request.EncryptedContent, + ReplyToMessageId: request.ReplyToMessageId, + FromUserId: senderId, + RecipientId: request.GroupId, + SendAt: DateTime.UtcNow, + Media: request.MediaAttachments?.Select(f => new SendMedia( + f.MediaId, f.EncryptedKey, f.Type, f.MimeType)) ?? Array.Empty()); + + var result = await _groupService.SendMessageAsync(message); + + if (!result.IsSuccess) + throw result.Exception!; + + // Шлём всем участникам группы, кроме отправителя + await Clients.GroupExcept($"group_{request.GroupId}", Context.ConnectionId) + .SendAsync("ReceiveGroupMessage", message); + + // Отправителю тоже + await Clients.Caller.SendAsync("ReceiveGroupMessage", message); + } + public async Task Send(MessageRequest request) { if (string.IsNullOrWhiteSpace(request.EncryptedContent)) @@ -79,10 +109,13 @@ public class ChatsHub : Hub Media: request.MediaAttachments?.Select(f => new SendMedia( f.MediaId, f.EncryptedKey, f.Type, f.MimeType)) ?? Array.Empty()); - if (request.RecipientType == RecipientType.User) - { - await SendUser(message); - } + Result result = await _chatService.SendMessageAsync(message); + if(result.IsSuccess == false) + throw result.Exception; + + // Sending a message to the sender and recipient + await Clients.Group(message.RecipientId.ToString()).SendAsync("Receive", message); + await Clients.Group(message.FromUserId.ToString()).SendAsync("Receive", message); // TODO: Send to Group } catch (Exception ex) @@ -92,17 +125,6 @@ public class ChatsHub : Hub } } - private async Task SendUser(SendMessage sendMessage) - { - Result result = await _chatService.SendMessageAsync(sendMessage); - if(result.IsSuccess == false) - throw result.Exception; - - // Sending a message to the sender and recipient - await Clients.Group(sendMessage.RecipientId.ToString()).SendAsync("Receive", sendMessage); - await Clients.Group(sendMessage.FromUserId.ToString()).SendAsync("Receive", sendMessage); - } - private Guid GetUserId() { var userIdClaim = Context.User?.FindFirst("userID")?.Value; diff --git a/Govor.Application/Interfaces/IGroupService.cs b/Govor.Application/Interfaces/IGroupService.cs index 955dc32..91ed51e 100644 --- a/Govor.Application/Interfaces/IGroupService.cs +++ b/Govor.Application/Interfaces/IGroupService.cs @@ -1,8 +1,9 @@ +using Govor.Application.Interfaces.Messages; using Govor.Core.Models; namespace Govor.API.Services; -public interface IGroupService +public interface IGroupService : IMessageSendingService, IMessageManagementService { ChatGroup GetGroupByInvite(string code); } \ No newline at end of file diff --git a/Govor.Application/Interfaces/Messages/Parameters/Result.cs b/Govor.Application/Interfaces/Messages/Parameters/Result.cs index c07618e..7151e86 100644 --- a/Govor.Application/Interfaces/Messages/Parameters/Result.cs +++ b/Govor.Application/Interfaces/Messages/Parameters/Result.cs @@ -1,3 +1,3 @@ namespace Govor.Application.Interfaces.Messages.Parameters; -public record Result(bool IsSuccess, Exception Exception); \ No newline at end of file +public record Result(bool IsSuccess, Exception Exception, Guid messageId); \ No newline at end of file diff --git a/Govor.Application/Services/PrivateChatService.cs b/Govor.Application/Services/PrivateChatService.cs index f18dad5..786ad27 100644 --- a/Govor.Application/Services/PrivateChatService.cs +++ b/Govor.Application/Services/PrivateChatService.cs @@ -48,11 +48,12 @@ public class PrivateChatService : IChatService }; await _messages.AddAsync(message); - return new Result(true, null); + + return new Result(true, null, messageId); } catch (Exception ex) { - return new Result(false, ex); + return new Result(false, ex, Guid.Empty); } } diff --git a/Govor.Contracts/Requests/SignalR/GroupMessageRequest.cs b/Govor.Contracts/Requests/SignalR/GroupMessageRequest.cs new file mode 100644 index 0000000..a972541 --- /dev/null +++ b/Govor.Contracts/Requests/SignalR/GroupMessageRequest.cs @@ -0,0 +1,9 @@ +namespace Govor.Contracts.Requests.SignalR; + +public record GroupMessageRequest() +{ + public Guid GroupId { get; init; } + public string EncryptedContent { get; init; } = string.Empty; + public Guid? ReplyToMessageId { get; set; } + public List MediaAttachments { get; set; } = new(); +} \ No newline at end of file diff --git a/Govor.Contracts/Requests/SignalR/MessageRequest.cs b/Govor.Contracts/Requests/SignalR/MessageRequest.cs index ec1417f..09295d3 100644 --- a/Govor.Contracts/Requests/SignalR/MessageRequest.cs +++ b/Govor.Contracts/Requests/SignalR/MessageRequest.cs @@ -5,7 +5,6 @@ namespace Govor.Contracts.Requests.SignalR; public record MessageRequest { public Guid RecipientId { get; init; } - public RecipientType RecipientType { get; set; } public string EncryptedContent { get; init; } = string.Empty; public Guid? ReplyToMessageId { get; set; } public List MediaAttachments { get; set; } = new(); diff --git a/Govor.Contracts/Responses/SignalR/UserMessageResponse.cs b/Govor.Contracts/Responses/SignalR/UserMessageResponse.cs new file mode 100644 index 0000000..fa98fc0 --- /dev/null +++ b/Govor.Contracts/Responses/SignalR/UserMessageResponse.cs @@ -0,0 +1,12 @@ +using Govor.Contracts.Requests.SignalR; + +namespace Govor.Contracts.Responses.SignalR; + +public record UserMessageResponse +{ + public Guid Id { get; init; } + public Guid SenderId { get; init; } + public string EncryptedContent { get; init; } = string.Empty; + public Guid? ReplyToMessageId { get; set; } + public List MediaReferences { get; init; } = new List(); +} \ No newline at end of file diff --git a/Govor.Data/Repositories/MessagesRepository.cs b/Govor.Data/Repositories/MessagesRepository.cs index bad8ce2..2ef4755 100644 --- a/Govor.Data/Repositories/MessagesRepository.cs +++ b/Govor.Data/Repositories/MessagesRepository.cs @@ -118,7 +118,7 @@ public class MessagesRepository : IMessagesRepository .SetProperty(m => m.ReplyToMessageId, message.ReplyToMessageId) .SetProperty(m => m.MessageViews, message.MessageViews) .SetProperty(m => m.MediaAttachments, message.MediaAttachments) - + ); if (rowsAffected == 0) From bdcfd32b1129a79507e9e3b0252f52e6b7a4053c Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Fri, 4 Jul 2025 14:33:54 +0700 Subject: [PATCH 024/101] the basis for group and private messages --- Govor.API/Controllers/InviteController.cs | 37 ++-- Govor.API/Hubs/ChatsHub.cs | 202 +++++++++++------- Govor.Application/Interfaces/IGroupService.cs | 15 +- .../Interfaces/Messages/IChatService.cs | 8 - .../Messages/IMessageManagementService.cs | 9 - .../Messages/IMessageReactionService.cs | 6 - .../Messages/IMessageSendingService.cs | 8 - .../Interfaces/Messages/IMessageService.cs | 30 +++ .../Messages/Parameters/DeleteMessage.cs | 5 + .../Messages/Parameters/EditMessage.cs | 7 + .../Messages/Parameters/SendMedia.cs | 5 +- .../Messages/Parameters/SendMessage.cs | 3 + Govor.Application/Services/MessageService.cs | 186 ++++++++++++++++ .../Services/PrivateChatService.cs | 69 ------ .../Requests/SignalR/MessageRequest.cs | 1 + .../Responses/SignalR/UserMessageResponse.cs | 11 +- .../Repositories/Groups/IGroupsExist.cs | 9 + .../Repositories/Groups/IGroupsReader.cs | 2 +- .../Repositories/Groups/IGroupsRepository.cs | 6 + .../Repositories/Groups/IGroupsWriter.cs | 6 + 20 files changed, 418 insertions(+), 207 deletions(-) delete mode 100644 Govor.Application/Interfaces/Messages/IChatService.cs delete mode 100644 Govor.Application/Interfaces/Messages/IMessageManagementService.cs delete mode 100644 Govor.Application/Interfaces/Messages/IMessageReactionService.cs delete mode 100644 Govor.Application/Interfaces/Messages/IMessageSendingService.cs create mode 100644 Govor.Application/Interfaces/Messages/IMessageService.cs create mode 100644 Govor.Application/Interfaces/Messages/Parameters/DeleteMessage.cs create mode 100644 Govor.Application/Interfaces/Messages/Parameters/EditMessage.cs create mode 100644 Govor.Application/Services/MessageService.cs delete mode 100644 Govor.Application/Services/PrivateChatService.cs create mode 100644 Govor.Core/Repositories/Groups/IGroupsExist.cs create mode 100644 Govor.Core/Repositories/Groups/IGroupsRepository.cs create mode 100644 Govor.Core/Repositories/Groups/IGroupsWriter.cs diff --git a/Govor.API/Controllers/InviteController.cs b/Govor.API/Controllers/InviteController.cs index 0da0c13..26ff900 100644 --- a/Govor.API/Controllers/InviteController.cs +++ b/Govor.API/Controllers/InviteController.cs @@ -1,4 +1,5 @@ -using Govor.API.Services; +using Govor.Application.Interfaces; +using Govor.Application.Interfaces.Infrastructure.Extensions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -9,24 +10,36 @@ namespace Govor.API.Controllers; public class InviteController : ControllerBase { private readonly IGroupService _groupService; - - public InviteController(IGroupService groupService) + private readonly ILogger _logger; + private readonly ICurrentUserService _currentUser; + public InviteController(IGroupService groupService, + ILogger logger) { _groupService = groupService; + _logger = logger; } - [HttpGet("{code}")] [Authorize] + [HttpGet("{code}")] public IActionResult JoinGroup(string code) { - var group = _groupService.GetGroupByInvite(code); - if (group == null) return NotFound(); - - return Ok(new + try { - groupId = group.Id, - name = group.Name, - isChannel = group.IsChannel - }); + _groupService.AddUserToGroupByInvitationAsync(_currentUser.GetCurrentUserId(), code); + + var group = _groupService.GetGroupByInviteCode(code); + + return Ok(new + { + groupId = group.Id, + name = group.Name, + isChannel = group.IsChannel + }); + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + return StatusCode(500, ex.Message); + } } } diff --git a/Govor.API/Hubs/ChatsHub.cs b/Govor.API/Hubs/ChatsHub.cs index b61dba0..dd7f4eb 100644 --- a/Govor.API/Hubs/ChatsHub.cs +++ b/Govor.API/Hubs/ChatsHub.cs @@ -13,42 +13,138 @@ namespace Govor.API.Hubs; public class ChatsHub : Hub { private readonly ILogger _logger; - private readonly IChatService _chatService; - private readonly IGroupService _groupService; + private readonly IMessageService _messageService; - public ChatsHub(ILogger logger, - IChatService chatService - ) + public ChatsHub(ILogger logger, IMessageService messageService) { _logger = logger; - _chatService = chatService; + _messageService = messageService; } - + public override async Task OnConnectedAsync() { var userId = GetUserId(); - - if (userId != Guid.Empty) + if (userId == Guid.Empty) { - // Binding ConnectionId to UserId - await Groups.AddToGroupAsync(Context.ConnectionId, userId.ToString()); - _logger.LogInformation("User {UserId} connected with ConnectionId {ConnectionId}", userId, Context.ConnectionId); + _logger.LogWarning("User connected with invalid UserID claim."); + Context.Abort(); // Abort connection if userID is invalid + return; } - + + // Add user to their own group (for private messages and notifications) + await Groups.AddToGroupAsync(Context.ConnectionId, userId.ToString()); + _logger.LogInformation("User {UserId} connected with ConnectionId {ConnectionId} and added to their group", userId, Context.ConnectionId); + + // TODO: Add user to their chat groups - this might require fetching user's groups from a service + // var userGroups = await _userService.GetUserGroupsAsync(userId); + // foreach (var group in userGroups) + // { + // await Groups.AddToGroupAsync(Context.ConnectionId, $"group_{group.Id}"); + // } + await base.OnConnectedAsync(); } - + public override async Task OnDisconnectedAsync(Exception? exception) { - var userId = GetUserId(); + var userId = GetUserId(suppressException: true); // Suppress exception if userID is not found (e.g. connection aborted early) if (userId != Guid.Empty) { + // Remove user from their own group await Groups.RemoveFromGroupAsync(Context.ConnectionId, userId.ToString()); - _logger.LogInformation("User {UserId} disconnected", userId); + _logger.LogInformation("User {UserId} disconnected with ConnectionId {ConnectionId} and removed from their group", userId, Context.ConnectionId); + + // TODO: Remove user from their chat groups + // var userGroups = await _userService.GetUserGroupsAsync(userId); // This might be problematic if the service relies on the user being connected + // foreach (var group in userGroups) + // { + // await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"group_{group.Id}"); + // } } + else if (exception != null) + { + _logger.LogWarning(exception, "User disconnected with an exception and invalid UserID claim. ConnectionId: {ConnectionId}", Context.ConnectionId); + } + else + { + _logger.LogInformation("User disconnected with no exception and invalid UserID claim. ConnectionId: {ConnectionId}", Context.ConnectionId); + } + await base.OnDisconnectedAsync(exception); } + public async Task Send(MessageRequest request) + { + if (string.IsNullOrWhiteSpace(request.EncryptedContent) && (request.MediaAttachments == null || !request.MediaAttachments.Any())) + { + _logger.LogWarning("Empty message (no content and no attachments) received from user {UserId}", GetUserId()); + throw new ArgumentException("Message cannot be empty (must have content or attachments)."); + } + + var senderId = GetUserId(); + _logger.LogInformation("Message send initiated by {SenderId} to {RecipientId} of type {RecipientType}", senderId, request.RecipientId, request.RecipientType); + + var sendMessageParams = new SendMessage( + EncryptContent: request.EncryptedContent, + ReplyToMessageId: request.ReplyToMessageId, + FromUserId: senderId, + RecipientId: request.RecipientId, + RecipientType: request.RecipientType, + SendAt: DateTime.UtcNow, + Media: request.MediaAttachments?.Select(f => new SendMedia( + f.MediaId, f.EncryptedKey, f.Type, f.MimeType)) ?? Array.Empty() + ); + + try + { + var result = await _messageService.SendMessageAsync(sendMessageParams); + + if (!result.IsSuccess || result.MessageId == Guid.Empty) + { + _logger.LogError(result.Exception, "Failed to send message from {SenderId} to {RecipientId}. Error: {ErrorMessage}", senderId, request.RecipientId, result.Exception?.Message ?? "Unknown error"); + if (result.Exception != null) throw result.Exception; + throw new HubException("Failed to send message due to an internal error."); + } + + var messageResponse = new UserMessageResponse // Assuming a response DTO + { + MessageId = result.MessageId, + SenderId = senderId, + RecipientId = request.RecipientId, + RecipientType = request.RecipientType, + EncryptedContent = request.EncryptedContent, + SentAt = sendMessageParams.SendAt, + IsEdited = false, + MediaAttachments = request.MediaAttachments, + ReplyToMessageId = request.ReplyToMessageId + }; + + // Notify recipient (user or group) + if (request.RecipientType == RecipientType.User) + { + // Send to the recipient's personal group + await Clients.Group(request.RecipientId.ToString()).SendAsync("ReceiveMessage", messageResponse); + } + else if (request.RecipientType == RecipientType.Group) + { + // Send to all members of the group, including the sender if they are part of the group via a different connection + await Clients.Group($"group_{request.RecipientId}").SendAsync("ReceiveMessage", messageResponse); + } + + // Notify sender (confirmation) on their connection + await Clients.Caller.SendAsync("MessageSent", messageResponse); // Or use "ReceiveMessage" if the sender should also just get it like anyone else + + _logger.LogInformation("Message {MessageId} sent successfully from {SenderId} to {RecipientId} ({RecipientType})", result.MessageId, senderId, request.RecipientId, request.RecipientType); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error sending message from {SenderId} to {RecipientId}", senderId, request.RecipientId); + // Consider sending a specific error message to the caller instead of a generic HubException or rethrowing. + // For example: await Clients.Caller.SendAsync("SendMessageFailed", new { Error = ex.Message }); + throw new HubException("An error occurred while sending the message.", ex); + } + } + public async Task Remove(Guid recipientId, Guid messageId) { @@ -59,79 +155,19 @@ public class ChatsHub : Hub } - public async Task SendGroup(GroupMessageRequest request) - { - var senderId = GetUserId(); - - // Создание сообщения - var message = new SendMessage( - EncryptContent: request.EncryptedContent, - ReplyToMessageId: request.ReplyToMessageId, - FromUserId: senderId, - RecipientId: request.GroupId, - SendAt: DateTime.UtcNow, - Media: request.MediaAttachments?.Select(f => new SendMedia( - f.MediaId, f.EncryptedKey, f.Type, f.MimeType)) ?? Array.Empty()); - - var result = await _groupService.SendMessageAsync(message); - - if (!result.IsSuccess) - throw result.Exception!; - - // Шлём всем участникам группы, кроме отправителя - await Clients.GroupExcept($"group_{request.GroupId}", Context.ConnectionId) - .SendAsync("ReceiveGroupMessage", message); - - // Отправителю тоже - await Clients.Caller.SendAsync("ReceiveGroupMessage", message); - } - public async Task Send(MessageRequest request) - { - if (string.IsNullOrWhiteSpace(request.EncryptedContent)) - { - _logger.LogWarning("Empty message received from user {UserId}", GetUserId()); - throw new ArgumentException("Message cannot be empty", nameof(request.EncryptedContent)); - } - - var senderId = GetUserId(); - - try - { - _logger.LogInformation("Message sent from {SenderId} to {RecipientId} at {UtcNow}", senderId, request.RecipientId, DateTime.UtcNow); - - var message = new SendMessage( - EncryptContent: request.EncryptedContent, - ReplyToMessageId: request.ReplyToMessageId, - FromUserId: senderId, - RecipientId: request.RecipientId, - SendAt: DateTime.UtcNow, - Media: request.MediaAttachments?.Select(f => new SendMedia( - f.MediaId, f.EncryptedKey, f.Type, f.MimeType)) ?? Array.Empty()); - - Result result = await _chatService.SendMessageAsync(message); - if(result.IsSuccess == false) - throw result.Exception; - - // Sending a message to the sender and recipient - await Clients.Group(message.RecipientId.ToString()).SendAsync("Receive", message); - await Clients.Group(message.FromUserId.ToString()).SendAsync("Receive", message); - // TODO: Send to Group - } - catch (Exception ex) - { - _logger.LogError(ex, "Error sending message from {SenderId} to {RecipientId}", senderId, request.RecipientId); - throw; - } - } - private Guid GetUserId() + private Guid GetUserId(bool suppressException = false) { var userIdClaim = Context.User?.FindFirst("userID")?.Value; if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) { - _logger.LogError("Could not retrieve sender userId"); - throw new UnauthorizedAccessException("userID claim is missing or invalid"); + if (!suppressException) + { + _logger.LogError("Could not retrieve sender userId. Claim was: {UserIDClaim}", userIdClaim); + throw new UnauthorizedAccessException("userID claim is missing or invalid."); + } + return Guid.Empty; } return userId; } diff --git a/Govor.Application/Interfaces/IGroupService.cs b/Govor.Application/Interfaces/IGroupService.cs index 91ed51e..e57119c 100644 --- a/Govor.Application/Interfaces/IGroupService.cs +++ b/Govor.Application/Interfaces/IGroupService.cs @@ -1,9 +1,16 @@ -using Govor.Application.Interfaces.Messages; +using Govor.Application.Interfaces.Messages.Parameters; using Govor.Core.Models; -namespace Govor.API.Services; +namespace Govor.Application.Interfaces; -public interface IGroupService : IMessageSendingService, IMessageManagementService +public interface IGroupService { - ChatGroup GetGroupByInvite(string code); + Task GetGroupByIdAsync(Guid groupId); + Task CreateGroupAsync(string name, Guid creatorId, IEnumerable initialMemberIds); + Task AddUserToGroupByInvitationAsync(Guid userId, string invitationCode); + Task RemoveUserFromGroupAsync(Guid groupId, Guid userId, Guid removedByUserId); + Task DeleteGroupAsync(Guid groupId, Guid userId); + Task> GetGroupMembersAsync(Guid groupId); + Task>GetUserGroupsAsync(Guid userId); + ChatGroup GetGroupByInviteCode(string code); } \ No newline at end of file diff --git a/Govor.Application/Interfaces/Messages/IChatService.cs b/Govor.Application/Interfaces/Messages/IChatService.cs deleted file mode 100644 index e44dc9b..0000000 --- a/Govor.Application/Interfaces/Messages/IChatService.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Govor.Application.Interfaces.Messages; - -public interface IChatService : IMessageSendingService, IMessageManagementService -{ - -} - - diff --git a/Govor.Application/Interfaces/Messages/IMessageManagementService.cs b/Govor.Application/Interfaces/Messages/IMessageManagementService.cs deleted file mode 100644 index 9b35c43..0000000 --- a/Govor.Application/Interfaces/Messages/IMessageManagementService.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Govor.Application.Interfaces.Messages.Parameters; - -namespace Govor.Application.Interfaces.Messages; - -public interface IMessageManagementService -{ - Task EditMessageAsync(Guid editorId, Guid messageId, string newContent); - Task DeleteMessageAsync(Guid editorId, Guid messageId); -} \ No newline at end of file diff --git a/Govor.Application/Interfaces/Messages/IMessageReactionService.cs b/Govor.Application/Interfaces/Messages/IMessageReactionService.cs deleted file mode 100644 index c8b624a..0000000 --- a/Govor.Application/Interfaces/Messages/IMessageReactionService.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Govor.Application.Interfaces.Messages; - -public interface IMessageReactionService -{ - -} \ No newline at end of file diff --git a/Govor.Application/Interfaces/Messages/IMessageSendingService.cs b/Govor.Application/Interfaces/Messages/IMessageSendingService.cs deleted file mode 100644 index 1bee95f..0000000 --- a/Govor.Application/Interfaces/Messages/IMessageSendingService.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Govor.Application.Interfaces.Messages.Parameters; - -namespace Govor.Application.Interfaces.Messages; - -public interface IMessageSendingService -{ - Task SendMessageAsync(SendMessage newMessage); -} \ No newline at end of file diff --git a/Govor.Application/Interfaces/Messages/IMessageService.cs b/Govor.Application/Interfaces/Messages/IMessageService.cs new file mode 100644 index 0000000..850afcb --- /dev/null +++ b/Govor.Application/Interfaces/Messages/IMessageService.cs @@ -0,0 +1,30 @@ +using Govor.Application.Interfaces.Messages.Parameters; +using Govor.Core.Models; + +namespace Govor.Application.Interfaces.Messages; + +// Combining IChatService and IGroupService functionalities relevant to messages +public interface IMessageService +{ + Task SendMessageAsync(SendMessage messageParameters); + Task EditMessageAsync(EditMessage messageParameters); + Task DeleteMessageAsync(DeleteMessage messageParameters); + + // Potentially other message-related methods like: + // Task GetMessagesAsync(Guid userId, Guid chatId, RecipientType chatType, int pageNumber, int pageSize); + // Task MarkMessageAsReadAsync(Guid userId, Guid messageId); +} + +// Define specific result types for clarity, including original message for notifications if needed + +public record SendMessageResult(bool IsSuccess, Exception? Exception, Guid MessageId) + : Result(IsSuccess, Exception, MessageId); + +public record EditMessageResult(bool IsSuccess, Exception? Exception, Message? OriginalMessage) + : Result(IsSuccess, Exception, OriginalMessage?.Id ?? Guid.Empty) +{ + // OriginalMessage can be useful for the Hub to know details like RecipientType, RecipientId for notifications +} + +public record DeleteMessageResult(bool IsSuccess, Exception? Exception, Message? OriginalMessage) + : Result(IsSuccess, Exception, OriginalMessage?.Id ?? Guid.Empty); diff --git a/Govor.Application/Interfaces/Messages/Parameters/DeleteMessage.cs b/Govor.Application/Interfaces/Messages/Parameters/DeleteMessage.cs new file mode 100644 index 0000000..ceed39d --- /dev/null +++ b/Govor.Application/Interfaces/Messages/Parameters/DeleteMessage.cs @@ -0,0 +1,5 @@ +namespace Govor.Application.Interfaces.Messages.Parameters; + +public record DeleteMessage( + Guid DeleterId, + Guid MessageId); \ No newline at end of file diff --git a/Govor.Application/Interfaces/Messages/Parameters/EditMessage.cs b/Govor.Application/Interfaces/Messages/Parameters/EditMessage.cs new file mode 100644 index 0000000..4c0e6af --- /dev/null +++ b/Govor.Application/Interfaces/Messages/Parameters/EditMessage.cs @@ -0,0 +1,7 @@ +namespace Govor.Application.Interfaces.Messages.Parameters; + +public record EditMessage( + Guid EditorId, + Guid MessageId, + string NewContent, + DateTime EditedAt); \ No newline at end of file diff --git a/Govor.Application/Interfaces/Messages/Parameters/SendMedia.cs b/Govor.Application/Interfaces/Messages/Parameters/SendMedia.cs index 06a002d..1d87a71 100644 --- a/Govor.Application/Interfaces/Messages/Parameters/SendMedia.cs +++ b/Govor.Application/Interfaces/Messages/Parameters/SendMedia.cs @@ -2,7 +2,4 @@ using Govor.Core.Models; namespace Govor.Application.Interfaces.Messages.Parameters; -public record SendMedia(Guid Id, - string EncryptedKey, - MediaType Type, - string MimeType); \ No newline at end of file +public record SendMedia(Guid MediaId, string EncryptedKey, MediaType Type, string MimeType); \ No newline at end of file diff --git a/Govor.Application/Interfaces/Messages/Parameters/SendMessage.cs b/Govor.Application/Interfaces/Messages/Parameters/SendMessage.cs index 23c7cc8..b302276 100644 --- a/Govor.Application/Interfaces/Messages/Parameters/SendMessage.cs +++ b/Govor.Application/Interfaces/Messages/Parameters/SendMessage.cs @@ -1,8 +1,11 @@ +using Govor.Core.Models; + namespace Govor.Application.Interfaces.Messages.Parameters; public record SendMessage( string EncryptContent, Guid? ReplyToMessageId, + RecipientType RecipientType, Guid RecipientId, Guid FromUserId, DateTime SendAt, diff --git a/Govor.Application/Services/MessageService.cs b/Govor.Application/Services/MessageService.cs new file mode 100644 index 0000000..7131404 --- /dev/null +++ b/Govor.Application/Services/MessageService.cs @@ -0,0 +1,186 @@ +using Govor.Application.Interfaces; +using Govor.Application.Interfaces.Messages; +using Govor.Application.Interfaces.Messages.Parameters; +using Govor.Core.Models; +using Govor.Core.Repositories.Groups; +using Govor.Core.Repositories.Messages; +using Govor.Core.Repositories.Users; +using Microsoft.Extensions.Logging; + +namespace Govor.Application.Services; + +public class MessageService : IMessageService +{ + private readonly IMessagesRepository _messagesRepository; + private readonly IUsersRepository _usersRepository; // For validating user recipients + private readonly IGroupsRepository _groupsRepository; // For validating group recipients and fetching members + private readonly IVerifyFriendship _verifyFriendship; // For private messages + private readonly ILogger _logger; + + public MessageService( + IMessagesRepository messagesRepository, + IUsersRepository usersRepository, + IGroupsRepository groupsRepository, + IVerifyFriendship verifyFriendship, + ILogger logger) + { + _messagesRepository = messagesRepository; + _usersRepository = usersRepository; + _groupsRepository = groupsRepository; + _verifyFriendship = verifyFriendship; + _logger = logger; + } + + public async Task SendMessageAsync(SendMessage sendParams) + { + try + { + // Validate recipient + if (sendParams.RecipientType == RecipientType.User) + { + if (!await _usersRepository.ExistsByIdAsync(sendParams.RecipientId)) // Changed to ExistsByIdAsync + { + _logger.LogWarning("Attempt to send message to non-existent user {RecipientId}", sendParams.RecipientId); + return new SendMessageResult(false, new KeyNotFoundException($"Recipient user {sendParams.RecipientId} not found."), Guid.Empty); + } + // Verify friendship for private messages + await _verifyFriendship.VerifyAsync(sendParams.FromUserId, sendParams.RecipientId); + } + else if (sendParams.RecipientType == RecipientType.Group) + { + if (!_groupsRepository.Exists(sendParams.RecipientId)) + { + _logger.LogWarning("Attempt to send message to non-existent group {GroupId}", sendParams.RecipientId); + return new SendMessageResult(false, new KeyNotFoundException($"Recipient group {sendParams.RecipientId} not found."), Guid.Empty); + } + // TODO: Optionally, verify if sender is a member of the group + // bool isMember = await _groupsRepository.IsUserMemberOfGroupAsync(sendParams.FromUserId, sendParams.RecipientId); + // if (!isMember) + // { + // _logger.LogWarning("User {UserId} attempted to send message to group {GroupId} but is not a member", sendParams.FromUserId, sendParams.RecipientId); + // return new SendMessageResult(false, new UnauthorizedAccessException("Sender is not a member of the group."), Guid.Empty); + // } + } + else + { + _logger.LogError("Invalid recipient type specified: {RecipientType}", sendParams.RecipientType); + return new SendMessageResult(false, new ArgumentException("Invalid recipient type."), Guid.Empty); + } + + var messageId = Guid.NewGuid(); + var message = new Message + { + Id = messageId, + SenderId = sendParams.FromUserId, + RecipientId = sendParams.RecipientId, + RecipientType = sendParams.RecipientType, + EncryptedContent = sendParams.EncryptContent, + SentAt = sendParams.SendAt, + IsEdited = false, + ReplyToMessageId = sendParams.ReplyToMessageId, + MediaAttachments = sendParams.Media?.Select(m => new MediaAttachments + { + Id = m.MediaId, // Assuming SendMedia provides Id or it's generated here/by repo + MessageId = messageId, + Type = m.Type, + MimeType = m.MimeType, + EncryptedKey = m.EncryptedKey, + // FilePath will be set by storage service or handled by repository if media ID is pre-generated + }).ToList() ?? new List() + }; + + await _messagesRepository.AddAsync(message); + _logger.LogInformation("Message {MessageId} from {SenderId} to {RecipientId} ({RecipientType}) saved successfully.", messageId, sendParams.FromUserId, sendParams.RecipientId, sendParams.RecipientType); + return new SendMessageResult(true, null, messageId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error sending message from {SenderId} to {RecipientId} ({RecipientType})", sendParams.FromUserId, sendParams.RecipientId, sendParams.RecipientType); + return new SendMessageResult(false, ex, Guid.Empty); + } + } + + public async Task EditMessageAsync(EditMessage editParams) + { + try + { + var message = await _messagesRepository.FindByIdAsync(editParams.MessageId); + + if (message.SenderId != editParams.EditorId) + { + _logger.LogWarning("User {EditorId} attempted to edit message {MessageId} not sent by them (sender was {SenderId})", editParams.EditorId, editParams.MessageId, message.SenderId); + return new EditMessageResult(false, new UnauthorizedAccessException("User is not authorized to edit this message."), null); + } + + // TODO: Add a time limit for editing messages? e.g., if (message.SentAt < DateTime.UtcNow.AddMinutes(-15)) throw new Exception("Edit time limit exceeded"); + + // Keep a copy of the original message state for the result, if needed by Hub for notifications + var originalMessageForNotification = new Message + { + Id = message.Id, + SenderId = message.SenderId, + RecipientId = message.RecipientId, + RecipientType = message.RecipientType, + // Populate other fields if necessary for the notification + }; + + message.EncryptedContent = editParams.NewContent; + message.IsEdited = true; + message.EditedAt = editParams.EditedAt; + + await _messagesRepository.UpdateAsync(message); + _logger.LogInformation("Message {MessageId} edited successfully by user {EditorId}", editParams.MessageId, editParams.EditorId); + return new EditMessageResult(true, null, originalMessageForNotification); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error editing message {MessageId} by user {EditorId}", editParams.MessageId, editParams.EditorId); + return new EditMessageResult(false, ex, null); + } + } + + public async Task DeleteMessageAsync(DeleteMessage deleteParams) + { + try + { + var message = await _messagesRepository.FindByIdAsync(deleteParams.MessageId); + + if (message.SenderId != deleteParams.DeleterId) + { + // TODO: Allow group admins to delete messages in their groups? + // if (message.RecipientType == RecipientType.Group) { + // bool isAdmin = await _groupsRepository.IsUserAdminOfGroupAsync(deleteParams.DeleterId, message.RecipientId); + // if (!isAdmin) { + // _logger.LogWarning("User {DeleterId} (not sender or admin) attempted to delete group message {MessageId}", deleteParams.DeleterId, deleteParams.MessageId); + // return new DeleteMessageResult(false, new UnauthorizedAccessException("User is not authorized to delete this message."), null); + // } + // } else { + _logger.LogWarning("User {DeleterId} attempted to delete message {MessageId} not sent by them (sender was {SenderId})", deleteParams.DeleterId, deleteParams.MessageId, message.SenderId); + return new DeleteMessageResult(false, new UnauthorizedAccessException("User is not authorized to delete this message."), null); + // } + } + + // Keep a copy of the original message state for the result, if needed by Hub for notifications + var originalMessageForNotification = new Message + { + Id = message.Id, + SenderId = message.SenderId, + RecipientId = message.RecipientId, + RecipientType = message.RecipientType, + // Populate other fields if necessary for the notification + }; + + await _messagesRepository.RemoveAsync(deleteParams.MessageId); + + // TODO: Delete associated media attachments from storage if they are no longer referenced. + + _logger.LogInformation("Message {MessageId} deleted successfully by user {DeleterId}", deleteParams.MessageId, deleteParams.DeleterId); + return new DeleteMessageResult(true, null, originalMessageForNotification); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting message {MessageId} by user {DeleterId}", deleteParams.MessageId, deleteParams.DeleterId); + return new DeleteMessageResult(false, ex, null); + } + } +} \ No newline at end of file diff --git a/Govor.Application/Services/PrivateChatService.cs b/Govor.Application/Services/PrivateChatService.cs deleted file mode 100644 index 786ad27..0000000 --- a/Govor.Application/Services/PrivateChatService.cs +++ /dev/null @@ -1,69 +0,0 @@ -using Govor.Application.Interfaces; -using Govor.Application.Interfaces.Messages; -using Govor.Application.Interfaces.Messages.Parameters; -using Govor.Core.Models; -using Govor.Core.Repositories.Messages; -using Govor.Data; -using Microsoft.EntityFrameworkCore; - -namespace Govor.Application.Services; - -public class PrivateChatService : IChatService -{ - private readonly IVerifyFriendship _verifyFriendship; - private readonly IMessagesRepository _messages; - - public PrivateChatService(IMessagesRepository messages, IVerifyFriendship verifyFriendship) - { - _messages = messages; - _verifyFriendship = verifyFriendship; - } - - public async Task SendMessageAsync(SendMessage newMessage) - { - try - { - await _verifyFriendship.VerifyAsync(newMessage.FromUserId, newMessage.RecipientId); - - var messageId = Guid.NewGuid(); - - var message = new Message() - { - Id = messageId, - EncryptedContent = newMessage.EncryptContent, - IsEdited = false, - SenderId = newMessage.FromUserId, - RecipientId = newMessage.RecipientId, - RecipientType = RecipientType.User, - ReplyToMessageId = newMessage.ReplyToMessageId, - - MediaAttachments = newMessage.Media.Select(m => new MediaAttachments() - { - Id = m.Id, - MessageId = messageId, - Type = m.Type, - MimeType = m.MimeType, - EncryptedKey = m.EncryptedKey, - }).ToList() - }; - - await _messages.AddAsync(message); - - return new Result(true, null, messageId); - } - catch (Exception ex) - { - return new Result(false, ex, Guid.Empty); - } - } - - public Task EditMessageAsync(Guid editorId, Guid messageId, string newContent) - { - throw new NotImplementedException(); - } - - public Task DeleteMessageAsync(Guid editorId, Guid messageId) - { - throw new NotImplementedException(); - } -} \ No newline at end of file diff --git a/Govor.Contracts/Requests/SignalR/MessageRequest.cs b/Govor.Contracts/Requests/SignalR/MessageRequest.cs index 09295d3..fdce57f 100644 --- a/Govor.Contracts/Requests/SignalR/MessageRequest.cs +++ b/Govor.Contracts/Requests/SignalR/MessageRequest.cs @@ -5,6 +5,7 @@ namespace Govor.Contracts.Requests.SignalR; public record MessageRequest { public Guid RecipientId { get; init; } + public RecipientType RecipientType { get; init; } public string EncryptedContent { get; init; } = string.Empty; public Guid? ReplyToMessageId { get; set; } public List MediaAttachments { get; set; } = new(); diff --git a/Govor.Contracts/Responses/SignalR/UserMessageResponse.cs b/Govor.Contracts/Responses/SignalR/UserMessageResponse.cs index fa98fc0..6380a05 100644 --- a/Govor.Contracts/Responses/SignalR/UserMessageResponse.cs +++ b/Govor.Contracts/Responses/SignalR/UserMessageResponse.cs @@ -1,12 +1,17 @@ using Govor.Contracts.Requests.SignalR; +using Govor.Core.Models; namespace Govor.Contracts.Responses.SignalR; public record UserMessageResponse { - public Guid Id { get; init; } + public Guid MessageId { get; init; } public Guid SenderId { get; init; } + public Guid RecipientId { get; init; } + public RecipientType RecipientType{get; init; } public string EncryptedContent { get; init; } = string.Empty; - public Guid? ReplyToMessageId { get; set; } - public List MediaReferences { get; init; } = new List(); + public Guid? ReplyToMessageId { get; init; } + public DateTime SentAt { get; init; } + public bool IsEdited { get; init; } = false; + public List MediaAttachments { get; init; } = new List(); } \ No newline at end of file diff --git a/Govor.Core/Repositories/Groups/IGroupsExist.cs b/Govor.Core/Repositories/Groups/IGroupsExist.cs new file mode 100644 index 0000000..4e95afa --- /dev/null +++ b/Govor.Core/Repositories/Groups/IGroupsExist.cs @@ -0,0 +1,9 @@ +using Govor.Core.Models; + +namespace Govor.Core.Repositories.Groups; + +public interface IGroupsExist +{ + public bool Exists(Guid groupId); + public bool Exists(ChatGroup chatGroup); +} \ No newline at end of file diff --git a/Govor.Core/Repositories/Groups/IGroupsReader.cs b/Govor.Core/Repositories/Groups/IGroupsReader.cs index 41f1924..df38bb8 100644 --- a/Govor.Core/Repositories/Groups/IGroupsReader.cs +++ b/Govor.Core/Repositories/Groups/IGroupsReader.cs @@ -1,6 +1,6 @@ namespace Govor.Core.Repositories.Groups; -public class IGroupsReader +public interface IGroupsReader { } \ No newline at end of file diff --git a/Govor.Core/Repositories/Groups/IGroupsRepository.cs b/Govor.Core/Repositories/Groups/IGroupsRepository.cs new file mode 100644 index 0000000..cf9482f --- /dev/null +++ b/Govor.Core/Repositories/Groups/IGroupsRepository.cs @@ -0,0 +1,6 @@ +namespace Govor.Core.Repositories.Groups; + +public interface IGroupsRepository : IGroupsReader, IGroupsExist, IGroupsWriter +{ + +} \ No newline at end of file diff --git a/Govor.Core/Repositories/Groups/IGroupsWriter.cs b/Govor.Core/Repositories/Groups/IGroupsWriter.cs new file mode 100644 index 0000000..2fc631c --- /dev/null +++ b/Govor.Core/Repositories/Groups/IGroupsWriter.cs @@ -0,0 +1,6 @@ +namespace Govor.Core.Repositories.Groups; + +public interface IGroupsWriter +{ + +} \ No newline at end of file From 89f14c134fc6db422e86883e8543f72223768edc Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Sun, 6 Jul 2025 14:22:50 +0700 Subject: [PATCH 025/101] VerifyFriendshipTests --- .../Services/VerifyFriendshipTests.cs | 96 +++++++++++++++++++ .../ConfigurationProgramExtensions.cs | 4 + Govor.Application/Services/MessageService.cs | 11 +-- .../Services/VerifierFriendship.cs | 39 +++++--- 4 files changed, 132 insertions(+), 18 deletions(-) create mode 100644 Govor.API.Tests/UnitTests/Services/VerifyFriendshipTests.cs diff --git a/Govor.API.Tests/UnitTests/Services/VerifyFriendshipTests.cs b/Govor.API.Tests/UnitTests/Services/VerifyFriendshipTests.cs new file mode 100644 index 0000000..721f69b --- /dev/null +++ b/Govor.API.Tests/UnitTests/Services/VerifyFriendshipTests.cs @@ -0,0 +1,96 @@ +using AutoFixture; +using Govor.Application.Exceptions.VerifyFriendship; +using Govor.Application.Services; +using Govor.Core.Models; +using Govor.Core.Repositories.Friendships; +using Govor.Data.Repositories.Exceptions; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Govor.API.Tests.UnitTests.Services; + +[TestFixture] +public class VerifyFriendshipTests +{ + private Fixture _fixture; + private Mock> _mockLogger; + private Mock _mockFriendships; + private VerifyFriendship _service; + + [SetUp] + public void SetUp() + { + _fixture = new Fixture(); + _fixture.Behaviors + .OfType() + .ToList() + .ForEach(b => _fixture.Behaviors.Remove(b)); + _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); + + _mockLogger = new Mock>(); + _mockFriendships = new Mock(); + + _service = new VerifyFriendship(_mockFriendships.Object, _mockLogger.Object); + } + + // Test for VerifyAsync action + [Test] + public async Task VerifyAsync_Success() + { + // Arrange + var friendship1 = _fixture.Create(); + friendship1.Status = FriendshipStatus.Accepted; + _mockFriendships.Setup(f => f.FindByUserIdAsync(friendship1.RequesterId)) + .ReturnsAsync([friendship1]); + // Act + await _service.VerifyAsync(friendship1.RequesterId, friendship1.AddresseeId); + // Assert + _mockFriendships.Verify(f => f.FindByUserIdAsync(friendship1.RequesterId), Times.Once()); + Assert.That(await _service.TryVerifyAsync(friendship1.RequesterId, friendship1.AddresseeId), Is.EqualTo(true)); + } + + [Test] + public async Task VerifyAsync_IDsEmpty() + { + // Arrange + var friendship1 = _fixture.Create(); + friendship1.RequesterId = Guid.Empty; + friendship1.AddresseeId = Guid.Empty; + + // Act & Assert + Assert.ThrowsAsync(() => _service.VerifyAsync(friendship1.RequesterId, friendship1.AddresseeId)); + Assert.That(await _service.TryVerifyAsync(friendship1.RequesterId, friendship1.AddresseeId), Is.EqualTo(false)); + } + + [Test] + public async Task VerifyAsync_ThrowsNotFoundByKeyException_ThrowsFriendshipException() + { + // Arrange + var friendship1 = _fixture.Create(); + friendship1.Status = FriendshipStatus.Accepted; + + _mockFriendships.Setup(f => f.FindByUserIdAsync(friendship1.RequesterId)) + .ThrowsAsync(new NotFoundByKeyException(friendship1.RequesterId)); + + // Act & Assert + Assert.ThrowsAsync(async () => await _service.VerifyAsync(friendship1.RequesterId, friendship1.AddresseeId)); + _mockFriendships.Verify(f => f.FindByUserIdAsync(friendship1.RequesterId), Times.Once()); + Assert.That(await _service.TryVerifyAsync(friendship1.RequesterId, friendship1.AddresseeId), Is.EqualTo(false)); + } + + [Test] + public async Task VerifyAsync_ReturnsEmptyFriendships_ThrowsFriendshipException() + { + // Arrange + var friendship1 = _fixture.Create(); + friendship1.Status = FriendshipStatus.Accepted; + + _mockFriendships.Setup(f => f.FindByUserIdAsync(friendship1.RequesterId)) + .ReturnsAsync(new List()); + + // Act & Assert + Assert.ThrowsAsync(async () => await _service.VerifyAsync(friendship1.RequesterId, friendship1.AddresseeId)); + _mockFriendships.Verify(f => f.FindByUserIdAsync(friendship1.RequesterId), Times.Once()); + Assert.That(await _service.TryVerifyAsync(friendship1.RequesterId, friendship1.AddresseeId), Is.EqualTo(false)); + } +} \ No newline at end of file diff --git a/Govor.API/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Extensions/ConfigurationProgramExtensions.cs index 15281fe..985c5b7 100644 --- a/Govor.API/Extensions/ConfigurationProgramExtensions.cs +++ b/Govor.API/Extensions/ConfigurationProgramExtensions.cs @@ -6,6 +6,7 @@ using Govor.Application.Interfaces; using Govor.Application.Interfaces.AdminsStuff; using Govor.Application.Interfaces.Authentication; using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.Application.Interfaces.Messages; using Govor.Application.Services; using Govor.Core.Infrastructure.Extensions; using Govor.Core.Infrastructure.Validators; @@ -46,6 +47,9 @@ public static class ConfigurationProgramExtensions services.AddMemoryCache(); services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); } public static void AddRepositories(this IServiceCollection services) diff --git a/Govor.Application/Services/MessageService.cs b/Govor.Application/Services/MessageService.cs index 7131404..d423fdb 100644 --- a/Govor.Application/Services/MessageService.cs +++ b/Govor.Application/Services/MessageService.cs @@ -114,14 +114,17 @@ public class MessageService : IMessageService // TODO: Add a time limit for editing messages? e.g., if (message.SentAt < DateTime.UtcNow.AddMinutes(-15)) throw new Exception("Edit time limit exceeded"); - // Keep a copy of the original message state for the result, if needed by Hub for notifications var originalMessageForNotification = new Message { Id = message.Id, SenderId = message.SenderId, RecipientId = message.RecipientId, RecipientType = message.RecipientType, - // Populate other fields if necessary for the notification + SentAt = message.SentAt, + ReplyToMessageId = message.ReplyToMessageId, + Reactions = message.Reactions, + MediaAttachments = message.MediaAttachments, + MessageViews = message.MessageViews, }; message.EncryptedContent = editParams.NewContent; @@ -160,20 +163,16 @@ public class MessageService : IMessageService // } } - // Keep a copy of the original message state for the result, if needed by Hub for notifications var originalMessageForNotification = new Message { Id = message.Id, SenderId = message.SenderId, RecipientId = message.RecipientId, RecipientType = message.RecipientType, - // Populate other fields if necessary for the notification }; await _messagesRepository.RemoveAsync(deleteParams.MessageId); - // TODO: Delete associated media attachments from storage if they are no longer referenced. - _logger.LogInformation("Message {MessageId} deleted successfully by user {DeleterId}", deleteParams.MessageId, deleteParams.DeleterId); return new DeleteMessageResult(true, null, originalMessageForNotification); } diff --git a/Govor.Application/Services/VerifierFriendship.cs b/Govor.Application/Services/VerifierFriendship.cs index 9729110..7fc3c1a 100644 --- a/Govor.Application/Services/VerifierFriendship.cs +++ b/Govor.Application/Services/VerifierFriendship.cs @@ -2,11 +2,11 @@ using Govor.Application.Exceptions.VerifyFriendship; using Govor.Application.Interfaces; using Govor.Core.Models; using Govor.Core.Repositories.Friendships; +using Govor.Data.Repositories.Exceptions; using Microsoft.Extensions.Logging; namespace Govor.Application.Services; - public class VerifyFriendship : IVerifyFriendship { private readonly IFriendshipsRepository _friendshipsRepository; @@ -21,23 +21,38 @@ public class VerifyFriendship : IVerifyFriendship public async Task VerifyAsync(Guid targetUserId, Guid friendUserId) { - if (targetUserId == Guid.Empty || friendUserId == Guid.Empty) + try { - _logger?.LogWarning("Invalid user IDs provided: targetUserId={TargetUserId}, friendUserId={FriendUserId}", targetUserId, friendUserId); - throw new ArgumentException("User IDs cannot be empty.", nameof(targetUserId)); - } + if (targetUserId == Guid.Empty || friendUserId == Guid.Empty) + { + _logger?.LogWarning( + "Invalid user IDs provided: targetUserId={TargetUserId}, friendUserId={FriendUserId}", targetUserId, + friendUserId); + throw new ArgumentException("User IDs cannot be empty.", nameof(targetUserId)); + } - var friendships = await _friendshipsRepository.FindByUserIdAsync(targetUserId); - var friendship = friendships.Where(f => f.AddresseeId == friendUserId || f.RequesterId == friendUserId)?.FirstOrDefault(); - - if (friendship == null || friendship.Status != FriendshipStatus.Accepted) + var friendships = await _friendshipsRepository.FindByUserIdAsync(targetUserId); + var friendship = friendships.Where(f => f.AddresseeId == friendUserId || f.RequesterId == friendUserId) + ?.FirstOrDefault(); + + if (friendship == null || friendship.Status != FriendshipStatus.Accepted) + { + var errorMessage = string.Format(FriendshipNotAcceptedError, targetUserId, friendUserId); + _logger?.LogError(errorMessage); + throw new FriendshipException(errorMessage); + } + + _logger?.LogInformation( + "Friendship verified successfully for targetUserId={TargetUserId}, friendUserId={FriendUserId}", + targetUserId, friendUserId); + } + catch (NotFoundByKeyException ex) { var errorMessage = string.Format(FriendshipNotAcceptedError, targetUserId, friendUserId); _logger?.LogError(errorMessage); + throw new FriendshipException(errorMessage); } - - _logger?.LogInformation("Friendship verified successfully for targetUserId={TargetUserId}, friendUserId={FriendUserId}", targetUserId, friendUserId); } public async Task TryVerifyAsync(Guid targetUserId, Guid friendUserId) @@ -47,7 +62,7 @@ public class VerifyFriendship : IVerifyFriendship await VerifyAsync(targetUserId, friendUserId); return true; } - catch (FriendshipException ex) + catch (Exception ex) { return false; } From 606211365509243bd87450de9277532d7f6634c5 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Sun, 6 Jul 2025 15:44:45 +0700 Subject: [PATCH 026/101] Add unit tests and implement group repository interfaces Introduces comprehensive unit tests for MessageService covering send, edit, and delete message scenarios. Adds and implements IGroupsRepository, IGroupsReader, and IGroupsWriter interfaces with method stubs in GroupRepository. Updates ChatGroup model with Description and ImageId fields, and registers GroupRepository in DI. Fixes parameter order in SendMessage record. --- .../UnitTests/Services/MessageServiceTests.cs | 318 ++++++++++++++++++ .../ConfigurationProgramExtensions.cs | 2 + Govor.API/Hubs/ChatsHub.cs | 2 +- .../Messages/Parameters/SendMessage.cs | 2 +- Govor.Core/Models/ChatGroup.cs | 3 + .../Repositories/Groups/IGroupsReader.cs | 9 +- .../Repositories/Groups/IGroupsRepository.cs | 2 +- .../Repositories/Groups/IGroupsWriter.cs | 6 +- Govor.Data/Repositories/GroupRepository.cs | 60 +++- 9 files changed, 397 insertions(+), 7 deletions(-) create mode 100644 Govor.API.Tests/UnitTests/Services/MessageServiceTests.cs diff --git a/Govor.API.Tests/UnitTests/Services/MessageServiceTests.cs b/Govor.API.Tests/UnitTests/Services/MessageServiceTests.cs new file mode 100644 index 0000000..d789064 --- /dev/null +++ b/Govor.API.Tests/UnitTests/Services/MessageServiceTests.cs @@ -0,0 +1,318 @@ +using Govor.Application.Exceptions.VerifyFriendship; +using Govor.Application.Interfaces; +using Govor.Application.Interfaces.Messages.Parameters; +using Govor.Application.Services; +using Govor.Core.Models; +using Govor.Core.Repositories.Groups; +using Govor.Core.Repositories.Messages; +using Govor.Core.Repositories.Users; +using Govor.Data.Repositories.Exceptions; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Govor.API.Tests.UnitTests.Services; + +[TestFixture] +public class MessageServiceTests +{ + private Mock _mockMessagesRepo; + private Mock _mockUsersRepo; + private Mock _mockGroupsRepo; + private Mock _mockVerifyFriendship; + private Mock> _mockLogger; + private MessageService _messageService; + + [SetUp] + public void SetUp() + { + _mockMessagesRepo = new Mock(); + _mockUsersRepo = new Mock(); + _mockGroupsRepo = new Mock(); + _mockVerifyFriendship = new Mock(); + _mockLogger = new Mock>(); + + _messageService = new MessageService( + _mockMessagesRepo.Object, + _mockUsersRepo.Object, + _mockGroupsRepo.Object, + _mockVerifyFriendship.Object, + _mockLogger.Object); + } + + + // Test for SendMessageAsync action + [Test] + public async Task SendMessageAsync_ToUser_Success() + { + // Arrange + var senderId = Guid.NewGuid(); + var recipientId = Guid.NewGuid(); + + var sendMessageParams = new SendMessage("Hello", + null, + recipientId, + RecipientType.User, + senderId, + DateTime.UtcNow, + new List()); + + _mockUsersRepo.Setup(r => r.ExistsByIdAsync(recipientId)).ReturnsAsync(true); + _mockVerifyFriendship.Setup(v => v.VerifyAsync(senderId, recipientId)).Returns(Task.CompletedTask); + _mockMessagesRepo.Setup(r => r.AddAsync(It.IsAny())).Returns(Task.CompletedTask); + + // Act + var result = await _messageService.SendMessageAsync(sendMessageParams); + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.IsSuccess, Is.EqualTo(true)); + Assert.That(result.Exception, Is.Null); + + _mockMessagesRepo.Verify(r => r.AddAsync(It.Is(m => + m.SenderId == senderId && + m.RecipientId == recipientId && + m.RecipientType == RecipientType.User && + m.EncryptedContent == "Hello")), Times.Once); + } + + [Test] + public async Task SendMessageAsync_ToGroup_Success() + { + // Arrange + var senderId = Guid.NewGuid(); + var groupId = Guid.NewGuid(); + + var sendMessageParams = new SendMessage("Hello Group", + null, + groupId, + RecipientType.Group, + senderId, + DateTime.UtcNow, + new List()); + + _mockGroupsRepo.Setup(r => r.Exists(groupId)).Returns(true); + _mockGroupsRepo.Setup(r => r.IsUserMemberOfGroupAsync(senderId, groupId)).Returns(true); + _mockMessagesRepo.Setup(r => r.AddAsync(It.IsAny())).Returns(Task.CompletedTask); + + // Act + var result = await _messageService.SendMessageAsync(sendMessageParams); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.IsSuccess, Is.EqualTo(true)); + Assert.That(result.Exception, Is.Null); + _mockMessagesRepo.Verify(r => r.AddAsync(It.Is(m => + m.RecipientId == groupId && + m.RecipientType == RecipientType.Group)), Times.Once); + } + + [Test] + public async Task SendMessageAsync_ToUser_RecipientNotFound_ReturnsFailure() + { + // Arrange + var senderId = Guid.NewGuid(); + var recipientId = Guid.NewGuid(); + + var sendMessageParams = new SendMessage("Hello", + null, + recipientId, + RecipientType.User, + senderId, + DateTime.UtcNow, + new List()); + + _mockUsersRepo.Setup(r => r.ExistsByIdAsync(recipientId)).ReturnsAsync(false); + + // Act + var result = await _messageService.SendMessageAsync(sendMessageParams); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.IsSuccess, Is.EqualTo(false)); + Assert.That(result.Exception, Is.Not.Null); + Assert.That(result.Exception,Is.TypeOf()); + Assert.That(result.MessageId, Is.EqualTo(Guid.Empty)); + } + + [Test] + public async Task SendMessageAsync_ToUser_FriendshipVerificationFails_ReturnsFailure() + { + // Arrange + var senderId = Guid.NewGuid(); + var recipientId = Guid.NewGuid(); + + var sendMessageParams = new SendMessage("Hello", + null, + recipientId, + RecipientType.User, + senderId, + DateTime.UtcNow, + new List() + ); + + _mockUsersRepo.Setup(r => r.ExistsByIdAsync(recipientId)).ReturnsAsync(true); + _mockVerifyFriendship.Setup(v => v.VerifyAsync(senderId, recipientId)).ThrowsAsync(new FriendshipException("Not friends")); + + // Act + var result = await _messageService.SendMessageAsync(sendMessageParams); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.IsSuccess, Is.EqualTo(false)); + Assert.That(result.Exception, Is.Not.Null); + Assert.That(result.Exception,Is.TypeOf()); + Assert.That(result.MessageId, Is.EqualTo(Guid.Empty)); + } + + // Test for EditMessageAsync action + [Test] + public async Task EditMessageAsync_Success() + { + // Arrange + var editorId = Guid.NewGuid(); + var messageId = Guid.NewGuid(); + + var originalMessage = new Message + { + Id = messageId, + SenderId = editorId, + EncryptedContent = "Old", + RecipientId = Guid.NewGuid(), + RecipientType = RecipientType.User + }; + + var editParams = new EditMessage(editorId, messageId, "New Content", DateTime.UtcNow); + + _mockMessagesRepo.Setup(r => r.FindByIdAsync(messageId)).ReturnsAsync(originalMessage); + _mockMessagesRepo.Setup(r => r.UpdateAsync(It.IsAny())).Returns(Task.CompletedTask); + + // Act + var result = await _messageService.EditMessageAsync(editParams); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.IsSuccess, Is.EqualTo(true)); + Assert.That(result.OriginalMessage, Is.Not.Null); + + Assert.That(messageId, Is.EqualTo(result.OriginalMessage!.Id)); + + _mockMessagesRepo.Verify(r => r.UpdateAsync(It.Is(m => + m.Id == messageId && + m.EncryptedContent == "New Content" && + m.IsEdited == true && + m.EditedAt == editParams.EditedAt)), Times.Once); + } + + [Test] + public async Task EditMessageAsync_MessageNotFound_ReturnsFailure() + { + // Arrange + var editorId = Guid.NewGuid(); + var messageId = Guid.NewGuid(); + var editParams = new EditMessage(editorId, messageId, "New Content", DateTime.UtcNow); + + _mockMessagesRepo.Setup(r => r.FindByIdAsync(messageId)). + ThrowsAsync(new NotFoundByKeyException(messageId)); + + // Act + var result = await _messageService.EditMessageAsync(editParams); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.IsSuccess, Is.EqualTo(false)); + Assert.That(result.Exception, Is.Not.Null); + Assert.That(result.Exception,Is.TypeOf>()); + Assert.That(result.OriginalMessage, Is.Null); + } + + [Test] + public async Task EditMessageAsync_NotSender_ReturnsFailure() + { + // Arrange + var editorId = Guid.NewGuid(); + var senderId = Guid.NewGuid(); // Different from editorId + var messageId = Guid.NewGuid(); + var originalMessage = new Message { Id = messageId, SenderId = senderId, EncryptedContent = "Old" }; + var editParams = new EditMessage(editorId, messageId, "New Content", DateTime.UtcNow); + + _mockMessagesRepo.Setup(r => r.FindByIdAsync(messageId)).ReturnsAsync(originalMessage); + + // Act + var result = await _messageService.EditMessageAsync(editParams); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.IsSuccess, Is.EqualTo(false)); + Assert.That(result.Exception, Is.Not.Null); + Assert.That(result.Exception,Is.TypeOf()); + Assert.That(result.OriginalMessage, Is.Null); + } + + // Test for DeleteMessageAsync action + [Test] + public async Task DeleteMessageAsync_Success() + { + // Arrange + var deleterId = Guid.NewGuid(); + var messageId = Guid.NewGuid(); + var originalMessage = new Message { Id = messageId, SenderId = deleterId, RecipientId = Guid.NewGuid(), RecipientType = RecipientType.User }; + var deleteParams = new DeleteMessage(deleterId, messageId); + + _mockMessagesRepo.Setup(r => r.FindByIdAsync(messageId)).ReturnsAsync(originalMessage); + _mockMessagesRepo.Setup(r => r.RemoveAsync(messageId)).Returns(Task.CompletedTask); + + // Act + var result = await _messageService.DeleteMessageAsync(deleteParams); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.IsSuccess, Is.EqualTo(true)); + Assert.That(result.OriginalMessage, Is.Not.Null); + Assert.That(messageId, Is.EqualTo(result.OriginalMessage!.Id)); + _mockMessagesRepo.Verify(r => r.RemoveAsync(messageId), Times.Once); + } + + [Test] + public async Task DeleteMessageAsync_MessageNotFound_ReturnsFailure() + { + // Arrange + var deleterId = Guid.NewGuid(); + var messageId = Guid.NewGuid(); + var deleteParams = new DeleteMessage(deleterId, messageId); + + _mockMessagesRepo.Setup(r => r.FindByIdAsync(messageId)). + ThrowsAsync(new NotFoundByKeyException(messageId)); + + // Act + var result = await _messageService.DeleteMessageAsync(deleteParams); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.IsSuccess, Is.EqualTo(false)); + Assert.That(result.Exception, Is.Not.Null); + Assert.That(result.Exception,Is.TypeOf>()); + Assert.That(result.OriginalMessage, Is.Null); + } + + [Test] + public async Task DeleteMessageAsync_NotSender_ReturnsFailure() + { + // Arrange + var deleterId = Guid.NewGuid(); + var senderId = Guid.NewGuid(); // Different + var messageId = Guid.NewGuid(); + var originalMessage = new Message { Id = messageId, SenderId = senderId }; + var deleteParams = new DeleteMessage(deleterId, messageId); + + _mockMessagesRepo.Setup(r => r.FindByIdAsync(messageId)).ReturnsAsync(originalMessage); + + // Act + var result = await _messageService.DeleteMessageAsync(deleteParams); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.IsSuccess, Is.EqualTo(false)); + Assert.That(result.Exception, Is.Not.Null); + Assert.That(result.Exception,Is.TypeOf()); + Assert.That(result.OriginalMessage, Is.Null); + } +} \ No newline at end of file diff --git a/Govor.API/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Extensions/ConfigurationProgramExtensions.cs index 985c5b7..44d19da 100644 --- a/Govor.API/Extensions/ConfigurationProgramExtensions.cs +++ b/Govor.API/Extensions/ConfigurationProgramExtensions.cs @@ -13,6 +13,7 @@ using Govor.Core.Infrastructure.Validators; using Govor.Core.Models; using Govor.Core.Repositories.Admins; using Govor.Core.Repositories.Friendships; +using Govor.Core.Repositories.Groups; using Govor.Core.Repositories.Invaites; using Govor.Core.Repositories.MediasAttachments; using Govor.Core.Repositories.Messages; @@ -60,6 +61,7 @@ public static class ConfigurationProgramExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } public static void AddValidators(this IServiceCollection services) diff --git a/Govor.API/Hubs/ChatsHub.cs b/Govor.API/Hubs/ChatsHub.cs index dd7f4eb..737db0f 100644 --- a/Govor.API/Hubs/ChatsHub.cs +++ b/Govor.API/Hubs/ChatsHub.cs @@ -9,7 +9,7 @@ using Microsoft.AspNetCore.SignalR; namespace Govor.API.Hubs; -[Authorize] +[Authorize] // api/chats public class ChatsHub : Hub { private readonly ILogger _logger; diff --git a/Govor.Application/Interfaces/Messages/Parameters/SendMessage.cs b/Govor.Application/Interfaces/Messages/Parameters/SendMessage.cs index b302276..f59e222 100644 --- a/Govor.Application/Interfaces/Messages/Parameters/SendMessage.cs +++ b/Govor.Application/Interfaces/Messages/Parameters/SendMessage.cs @@ -5,8 +5,8 @@ namespace Govor.Application.Interfaces.Messages.Parameters; public record SendMessage( string EncryptContent, Guid? ReplyToMessageId, - RecipientType RecipientType, Guid RecipientId, + RecipientType RecipientType, Guid FromUserId, DateTime SendAt, IEnumerable Media); \ No newline at end of file diff --git a/Govor.Core/Models/ChatGroup.cs b/Govor.Core/Models/ChatGroup.cs index 1912474..b1ae3c2 100644 --- a/Govor.Core/Models/ChatGroup.cs +++ b/Govor.Core/Models/ChatGroup.cs @@ -4,6 +4,9 @@ public class ChatGroup { public Guid Id { get; set; } public string Name { get; set; } + public string Description { get; set; } + public Guid ImageId { get; set; } + public List InviteCode { get; set; } public bool IsChannel { get; set; } public bool IsPrivate { get; set; } diff --git a/Govor.Core/Repositories/Groups/IGroupsReader.cs b/Govor.Core/Repositories/Groups/IGroupsReader.cs index df38bb8..752817d 100644 --- a/Govor.Core/Repositories/Groups/IGroupsReader.cs +++ b/Govor.Core/Repositories/Groups/IGroupsReader.cs @@ -1,6 +1,13 @@ +using System.Text.RegularExpressions; + namespace Govor.Core.Repositories.Groups; public interface IGroupsReader { - + public Task> GetAllAsync(); + public Task GetByIdAsync(Guid id); + public Task> FindByNameAsync(string name); + public Task> GetByAdminIdAsync(Guid adminId); + public Task> GetByUserIdAsync(Guid adminId); + public bool IsUserMemberOfGroupAsync(Guid userId, Guid groupId); } \ No newline at end of file diff --git a/Govor.Core/Repositories/Groups/IGroupsRepository.cs b/Govor.Core/Repositories/Groups/IGroupsRepository.cs index cf9482f..9b2faa7 100644 --- a/Govor.Core/Repositories/Groups/IGroupsRepository.cs +++ b/Govor.Core/Repositories/Groups/IGroupsRepository.cs @@ -1,6 +1,6 @@ namespace Govor.Core.Repositories.Groups; -public interface IGroupsRepository : IGroupsReader, IGroupsExist, IGroupsWriter +public interface IGroupsRepository : IGroupsReader, IGroupsWriter, IGroupsExist { } \ No newline at end of file diff --git a/Govor.Core/Repositories/Groups/IGroupsWriter.cs b/Govor.Core/Repositories/Groups/IGroupsWriter.cs index 2fc631c..dbb2ce4 100644 --- a/Govor.Core/Repositories/Groups/IGroupsWriter.cs +++ b/Govor.Core/Repositories/Groups/IGroupsWriter.cs @@ -1,6 +1,10 @@ +using System.Text.RegularExpressions; + namespace Govor.Core.Repositories.Groups; public interface IGroupsWriter { - + Task Add(Group group); + Task Update(Group group); + Task Remove(Guid groupId); } \ No newline at end of file diff --git a/Govor.Data/Repositories/GroupRepository.cs b/Govor.Data/Repositories/GroupRepository.cs index 18b2dbb..cfbc592 100644 --- a/Govor.Data/Repositories/GroupRepository.cs +++ b/Govor.Data/Repositories/GroupRepository.cs @@ -1,6 +1,62 @@ +using System.Text.RegularExpressions; +using Govor.Core.Models; +using Govor.Core.Repositories.Groups; + namespace Govor.Data.Repositories; -public class GroupRepository +public class GroupRepository : IGroupsRepository { + public Task> GetAllAsync() + { + throw new NotImplementedException(); + } + + public Task GetByIdAsync(Guid id) + { + throw new NotImplementedException(); + } + + public Task> FindByNameAsync(string name) + { + throw new NotImplementedException(); + } -} \ No newline at end of file + public Task> GetByAdminIdAsync(Guid adminId) + { + throw new NotImplementedException(); + } + + public Task> GetByUserIdAsync(Guid adminId) + { + throw new NotImplementedException(); + } + + public Task Add(Group group) + { + throw new NotImplementedException(); + } + + public Task Update(Group group) + { + throw new NotImplementedException(); + } + + public Task Remove(Guid groupId) + { + throw new NotImplementedException(); + } + public bool Exists(Guid groupId) + { + throw new NotImplementedException(); + } + + public bool Exists(ChatGroup chatGroup) + { + throw new NotImplementedException(); + } + + public bool IsUserMemberOfGroupAsync(Guid userId, Guid groupId) + { + throw new NotImplementedException(); + } +} From 857751f0ade713a49d4ef87b35bfc827e4d8522e Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Sun, 6 Jul 2025 16:02:45 +0700 Subject: [PATCH 027/101] userId update in ChatHub --- Govor.API/Hubs/ChatsHub.cs | 2 +- Govor.Console/Program.cs | 180 +++++++++++++++++++++++++++++++++---- 2 files changed, 164 insertions(+), 18 deletions(-) diff --git a/Govor.API/Hubs/ChatsHub.cs b/Govor.API/Hubs/ChatsHub.cs index 737db0f..aad3501 100644 --- a/Govor.API/Hubs/ChatsHub.cs +++ b/Govor.API/Hubs/ChatsHub.cs @@ -159,7 +159,7 @@ public class ChatsHub : Hub private Guid GetUserId(bool suppressException = false) { - var userIdClaim = Context.User?.FindFirst("userID")?.Value; + var userIdClaim = Context.User?.FindFirst("userId")?.Value; if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) { if (!suppressException) diff --git a/Govor.Console/Program.cs b/Govor.Console/Program.cs index 2f77dc7..e804006 100644 --- a/Govor.Console/Program.cs +++ b/Govor.Console/Program.cs @@ -4,6 +4,9 @@ using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using Govor.Contracts.Requests; +using Govor.Contracts.Requests.SignalR; +using Govor.Contracts.Responses.SignalR; +using Govor.Core.Models; /*==================================== @@ -24,9 +27,11 @@ namespace Govor.ConsoleClient { static string? AuthToken = null; static HttpClientService HttpService = new("https://govor-team-govor-88b3.twc1.net"); // поменяй URL на свой - private static FriendsClient friendsClient; + private static FriendsClient? friendsClient; + static HubConnection? _hubConnection; static Dictionary> ChatHistory = new(); - static string CurrentChatUser = null; + static string? CurrentChatUser = null; + static Guid CurrentChatUserId = Guid.Empty; static async Task Main() { @@ -71,6 +76,7 @@ namespace Govor.ConsoleClient sharedClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", AuthToken); friendsClient = new FriendsClient(sharedClient); + await InitializeHubConnection(); Console.WriteLine("[Успех] Вход выполнен. Токен сохранен."); } catch (Exception ex) @@ -96,6 +102,7 @@ namespace Govor.ConsoleClient sharedClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", AuthToken); friendsClient = new FriendsClient(sharedClient); + await InitializeHubConnection(); Console.WriteLine("[Успех] Регистрация завершена. Токен сохранен."); } catch (Exception ex) @@ -104,6 +111,11 @@ namespace Govor.ConsoleClient } break; case "/search": + if (friendsClient == null) + { + Console.WriteLine("[Ошибка] Сначала войдите в систему."); + break; + } Console.Write("Введите имя друга: "); var q = Console.ReadLine(); var foundUsers = await friendsClient.SearchAsync(q); @@ -115,17 +127,32 @@ namespace Govor.ConsoleClient case "/exit": Console.WriteLine("[Система] Выход..."); + if (_hubConnection != null) + { + await _hubConnection.StopAsync(); + await _hubConnection.DisposeAsync(); + } Environment.Exit(0); break; case "/friends": + if (friendsClient == null) + { + Console.WriteLine("[Ошибка] Сначала войдите в систему."); + break; + } var friends = await friendsClient.GetFriendsAsync(); foreach (var f in friends) { - Console.WriteLine($"{f.Username} | был онлайн: {f.WasOnline}"); + Console.WriteLine($"{f.Username} | был онлайн: {f.WasOnline} [{f.Id}]"); } break; case "/friend": + if (friendsClient == null) + { + Console.WriteLine("[Ошибка] Сначала войдите в систему."); + break; + } Console.Write("Введите ID пользователя, которому хотите отправить запрос: "); var targetId = Guid.Parse(Console.ReadLine()); await friendsClient.SendFriendRequestAsync(targetId); @@ -133,12 +160,22 @@ namespace Govor.ConsoleClient break; case "/accept": + if (friendsClient == null) + { + Console.WriteLine("[Ошибка] Сначала войдите в систему."); + break; + } Console.Write("Введите ID пользователя, чью заявку принимаете: "); var acceptId = Guid.Parse(Console.ReadLine()); await friendsClient.AcceptFriendRequestAsync(acceptId); Console.WriteLine("Принято"); break; case "/incoming": + if (friendsClient == null) + { + Console.WriteLine("[Ошибка] Сначала войдите в систему."); + break; + } var requests = await friendsClient.GetIncomingRequestsAsync(); foreach (var r in requests) { @@ -148,16 +185,25 @@ namespace Govor.ConsoleClient case "/chat": if (string.IsNullOrEmpty(argument)) { - Console.WriteLine("[Ошибка] Укажите имя друга"); + Console.WriteLine("[Ошибка] Укажите имя друга и ID через пробел (например /chat Egor GUID)"); break; } - CurrentChatUser = argument; - ShowChat(argument); + var chatArgs = argument.Split(' ', 2); + if (chatArgs.Length < 2 || !Guid.TryParse(chatArgs[1], out var chatUserId)) + { + Console.WriteLine("[Ошибка] Неверный формат. Укажите имя друга и ID (например /chat Egor GUID)"); + break; + } + + CurrentChatUser = chatArgs[0]; + CurrentChatUserId = chatUserId; + ShowChat(CurrentChatUser); break; case "/back": CurrentChatUser = null; + CurrentChatUserId = Guid.Empty; break; default: @@ -166,21 +212,90 @@ namespace Govor.ConsoleClient } } + static async Task InitializeHubConnection() + { + if (string.IsNullOrEmpty(AuthToken)) + { + Console.WriteLine("[Ошибка] Токен аутентификации отсутствует. Пожалуйста, войдите в систему."); + return; + } + + _hubConnection = new HubConnectionBuilder() + .WithUrl("https://govor-team-govor-88b3.twc1.net/api/chats", options => + { + options.AccessTokenProvider = () => Task.FromResult(AuthToken); + }) + .Build(); + + _hubConnection.On("ReceiveMessage", (message) => + { + var senderName = message.SenderId == GetMyUserId() ? "Ты" : CurrentChatUser ?? "Неизвестно"; // Simplified for now + var displayMessage = $"[{message.SentAt:HH:mm}] {senderName} >> {message.EncryptedContent}"; + + string chatKey = message.SenderId == GetMyUserId() ? message.RecipientId.ToString() : message.SenderId.ToString(); + + if (CurrentChatUser != null && (message.SenderId == CurrentChatUserId || message.RecipientId == CurrentChatUserId)) + { + if (!ChatHistory.ContainsKey(CurrentChatUser)) + { + ChatHistory[CurrentChatUser] = new List(); + } + ChatHistory[CurrentChatUser].Add(displayMessage); + Console.SetCursorPosition(0, Console.CursorTop); // Move cursor to beginning of line + Console.Write(new string(' ', Console.WindowWidth -1)); // Clear current line + Console.SetCursorPosition(0, Console.CursorTop); + Console.WriteLine($" * {displayMessage}"); // Display new message + Console.Write(">> "); // Re-display prompt + } + else + { + // Handle notification for messages not in the current chat - TBD + Console.WriteLine($"\n[Новое сообщение от {message.SenderId}]: {message.EncryptedContent}"); + Console.Write(CurrentChatUser == null ? "/> " : ">> "); + } + }); + + _hubConnection.On("MessageSent", (message) => + { + // Optional: Handle confirmation that message was sent successfully by the server + // This could be useful for updating UI to show message status (e.g., "sent", "delivered") + // For console, just logging or ignoring might be fine. + // Console.WriteLine($"[Система] Сообщение {message.MessageId} доставлено на сервер."); + }); + + try + { + await _hubConnection.StartAsync(); + Console.WriteLine("[SignalR] Соединение установлено."); + } + catch (Exception ex) + { + Console.WriteLine($"[SignalR Ошибка] {ex.Message}"); + } + } + + static Guid GetMyUserId() + { + if (AuthToken == null) return Guid.Empty; + var handler = new JwtSecurityTokenHandler(); + var jwtToken = handler.ReadJwtToken(AuthToken); + var userIdClaim = jwtToken.Claims.FirstOrDefault(claim => claim.Type == "userId"); + return userIdClaim != null && Guid.TryParse(userIdClaim.Value, out var userId) ? userId : Guid.Empty; + } + + static void ShowChat(string user) { Console.Clear(); Console.WriteLine($"/*===================================="); - Console.WriteLine($" * Личные сообщения | {user}"); + Console.WriteLine($" * Личные сообщения | {user} ({CurrentChatUserId})"); Console.WriteLine($" *===================================="); if (!ChatHistory.ContainsKey(user)) { - ChatHistory[user] = new List - { - $"[15:59] Вы добавили ({user}) в друзья", - $"[16:30] {user} >> Привет!", - $"[16:31] Ты >> Че как?" - }; + ChatHistory[user] = new List(); + // Example initial messages - consider fetching history if implementing that + // ChatHistory[user].Add($"[Система] Начало чата с {user}"); } foreach (var msg in ChatHistory[user]) @@ -189,12 +304,43 @@ namespace Govor.ConsoleClient Console.WriteLine(" *------------------------------------"); } - static void SendMessage(string message) + static async void SendMessage(string messageContent) { - if (string.IsNullOrWhiteSpace(message)) return; + if (string.IsNullOrWhiteSpace(messageContent) || _hubConnection == null || CurrentChatUserId == Guid.Empty) return; - ChatHistory[CurrentChatUser].Add($"Ты >> {message}"); - Console.WriteLine($">> {message}"); + var myUserId = GetMyUserId(); + if (myUserId == Guid.Empty) + { + Console.WriteLine("[Ошибка] Не удалось определить ID пользователя. Попробуйте войти снова."); + return; + } + + var messageRequest = new MessageRequest + { + RecipientId = CurrentChatUserId, + EncryptedContent = messageContent, // Assuming content is not actually encrypted for console client simplicity for now + RecipientType = RecipientType.User, + // MediaAttachments and ReplyToMessageId can be added if needed + }; + + try + { + await _hubConnection.InvokeAsync("Send", messageRequest); + + // Add to local history immediately for responsiveness. + // Server will send "ReceiveMessage" to self as well if needed, or "MessageSent" for confirmation. + var displayMessage = $"[{DateTime.Now:HH:mm}] Ты >> {messageContent}"; + if (!ChatHistory.ContainsKey(CurrentChatUser!)) + { + ChatHistory[CurrentChatUser!] = new List(); + } + ChatHistory[CurrentChatUser!].Add(displayMessage); + // Console.WriteLine($"Ты >> {messageContent}"); // Already handled by input echo + ReceiveMessage from self + } + catch (Exception ex) + { + Console.WriteLine($"[Ошибка отправки] {ex.Message}"); + } } static void LoginWindow() From 42348c863f778d8f3cc1a28ba81c0263ee50b701 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Sun, 6 Jul 2025 17:43:10 +0700 Subject: [PATCH 028/101] Add admin friendships controller and improve friend request handling Introduces FriendshipsController for admin operations, including listing and removing friendships. Updates routing for admin controllers, improves error handling in UsersController, and enhances FriendsController and related services to include requester details in friendship DTOs. Refactors friend request acceptance to use query parameters and updates console client for improved user feedback and local development configuration. --- .../AdminStuff/FriendshipsController.cs | 109 ++++++++++++++++++ .../AdminStuff/InviteUserController.cs | 2 +- .../Controllers/AdminStuff/UsersController.cs | 7 ++ Govor.API/Controllers/FriendsController.cs | 7 +- Govor.Application/Services/FriendsService.cs | 7 +- Govor.Console/FriendsClient.cs | 3 +- Govor.Console/Program.cs | 14 ++- Govor.Contracts/DTOs/FriendshipDto.cs | 1 + 8 files changed, 134 insertions(+), 16 deletions(-) create mode 100644 Govor.API/Controllers/AdminStuff/FriendshipsController.cs diff --git a/Govor.API/Controllers/AdminStuff/FriendshipsController.cs b/Govor.API/Controllers/AdminStuff/FriendshipsController.cs new file mode 100644 index 0000000..94160e0 --- /dev/null +++ b/Govor.API/Controllers/AdminStuff/FriendshipsController.cs @@ -0,0 +1,109 @@ +using Govor.Contracts.DTOs; +using Govor.Core.Models; +using Govor.Core.Repositories.Friendships; +using Govor.Data.Repositories.Exceptions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Govor.API.Controllers.AdminStuff; + +[ApiController] +[Route("api/admin/[controller]")] +[Authorize(Roles = "Admin")] +public class FriendshipsController : Controller +{ + private readonly ILogger _logger; + private readonly IFriendshipsRepository _friendshipsRepository; + + public FriendshipsController(ILogger logger, IFriendshipsRepository friendshipsRepository) + { + _logger = logger; + _friendshipsRepository = friendshipsRepository; + } + + [HttpGet] + public async Task Get() + { + try + { + _logger.LogInformation("Get all friendships by administrator"); + var result = await _friendshipsRepository.GetAllAsync(); + return Ok(BuildFriendshipDtos(result)); + } + catch (NotFoundException ex) + { + _logger.LogWarning(ex.Message); + return NotFound(ex.Message); + } + catch (Exception e) + { + _logger.LogError(e, e.Message); + return StatusCode(500, new { error = "Internal server error." }); + } + } + + [HttpGet("{userId}")] + public async Task GetByUserId(Guid userId) + { + if(userId == Guid.Empty) + return BadRequest("User id can't be empty."); + + try + { + _logger.LogInformation($"Get user's {userId} all friendships by administrator"); + var result = await _friendshipsRepository.FindByUserIdAsync(userId); + return Ok(BuildFriendshipDtos(result)); + } + catch (NotFoundByKeyException ex) + { + _logger.LogWarning(ex.Message); + return NotFound(ex.Message); + } + catch (Exception e) + { + _logger.LogError(e, e.Message); + return StatusCode(500, new { error = "Internal server error." }); + } + } + + [HttpPost] + public async Task RemoveFriendship(Guid Id) + { + if(Id == Guid.Empty) + return BadRequest("FS id can't be empty."); + try + { + var result = await _friendshipsRepository.GetByIdAsync(Id); + await _friendshipsRepository.RemoveAsync(result); + return Ok(); + } + catch (NotFoundByKeyException ex) + { + _logger.LogWarning(ex.Message); + return NotFound(ex.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + return StatusCode(500, new { error = "Internal server error." }); + } + } + + private List BuildUserDtos(IEnumerable users) => users.Select(user => new UserDto + { + Id = user.Id, + Username = user.Username, + Description = user.Description, + WasOnline = user.WasOnline, + IconId = user.IconId + }).ToList(); + + private List BuildFriendshipDtos(IEnumerable friendships) => friendships.Select(f => new FriendshipDto + { + Id = f.Id, + Status = f.Status, + AddresseeId = f.AddresseeId, + RequesterId = f.RequesterId, + Requester = BuildUserDtos([f.Requester]).First(), + }).ToList(); +} \ No newline at end of file diff --git a/Govor.API/Controllers/AdminStuff/InviteUserController.cs b/Govor.API/Controllers/AdminStuff/InviteUserController.cs index 372549a..d5d28c1 100644 --- a/Govor.API/Controllers/AdminStuff/InviteUserController.cs +++ b/Govor.API/Controllers/AdminStuff/InviteUserController.cs @@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Mvc; namespace Govor.API.Controllers.AdminStuff; -[Route("api/[controller]")] +[Route("api/admin/[controller]")] [ApiController] [Authorize(Roles = "Admin")] public class InviteUserController : Controller diff --git a/Govor.API/Controllers/AdminStuff/UsersController.cs b/Govor.API/Controllers/AdminStuff/UsersController.cs index 8dd1c6a..dd7e67f 100644 --- a/Govor.API/Controllers/AdminStuff/UsersController.cs +++ b/Govor.API/Controllers/AdminStuff/UsersController.cs @@ -1,6 +1,7 @@ using Govor.API.Services.AdminsStuff.Interfaces; using Govor.Contracts.Responses.Admins; using Govor.Core.Models; +using Govor.Data.Repositories.Exceptions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -48,6 +49,11 @@ public class UsersController : Controller var read = await _users.GetUserById(id); return Ok(BuildUserDtos([read]).First()); } + catch (NotFoundByKeyException ex) + { + _logger.LogWarning(ex, ex.Message); + return NotFound(ex.Message); + } catch (Exception e) { _logger.LogError(e, e.Message); @@ -55,6 +61,7 @@ public class UsersController : Controller } } + private List BuildUserDtos(IEnumerable users) => users.Select(user => new UserResponse { Id = user.Id, diff --git a/Govor.API/Controllers/FriendsController.cs b/Govor.API/Controllers/FriendsController.cs index d49a732..cfa6295 100644 --- a/Govor.API/Controllers/FriendsController.cs +++ b/Govor.API/Controllers/FriendsController.cs @@ -98,7 +98,7 @@ public class FriendsController : Controller } [HttpPost("accept")] - public async Task AcceptFriend(Guid friendshipId) + public async Task AcceptFriend([FromQuery] Guid friendshipId) { if (friendshipId == Guid.Empty) return BadRequest("Requester ID is invalid"); @@ -144,7 +144,7 @@ public class FriendsController : Controller return StatusCode(500, new { error = "Internal server error." }); } } - + private List BuildUserDtos(IEnumerable users) => users.Select(user => new UserDto { Id = user.Id, @@ -159,6 +159,7 @@ public class FriendsController : Controller Id = f.Id, Status = f.Status, AddresseeId = f.AddresseeId, - RequesterId = f.RequesterId + RequesterId = f.RequesterId, + Requester = BuildUserDtos([f.Requester]).First(), }).ToList(); } diff --git a/Govor.Application/Services/FriendsService.cs b/Govor.Application/Services/FriendsService.cs index c68f2bb..e24dcd4 100644 --- a/Govor.Application/Services/FriendsService.cs +++ b/Govor.Application/Services/FriendsService.cs @@ -90,6 +90,7 @@ public class FriendsService : IFriendsService var friendships = await _friendshipsRepository.FindByUserIdAsync(userId); return friendships + .Where(f => f.Status == FriendshipStatus.Accepted) .Select(f => f.RequesterId == userId ? f.Addressee : f.Requester) .ToList(); } @@ -103,10 +104,8 @@ public class FriendsService : IFriendsService { try { - var user = await _usersRepository.FindByIdAsync(userId); - return user.ReceivedFriendRequests - .Where(f => f.Status == FriendshipStatus.Pending) - .ToList(); + var friendships = await _friendshipsRepository.FindByUserIdAsync(userId); + return friendships.Where(f => f.AddresseeId == userId && f.Status == FriendshipStatus.Pending).ToList(); } catch (NotFoundByKeyException ex) { diff --git a/Govor.Console/FriendsClient.cs b/Govor.Console/FriendsClient.cs index 19e9b2d..21593f7 100644 --- a/Govor.Console/FriendsClient.cs +++ b/Govor.Console/FriendsClient.cs @@ -36,8 +36,7 @@ namespace Govor.ConsoleClient public async Task AcceptFriendRequestAsync(Guid requesterId) { - var content = JsonContent.Create(requesterId); - var response = await _client.PostAsync("/api/friends/accept", content); + var response = await _client.PostAsync($"/api/friends/accept?friendshipId={requesterId}", null); response.EnsureSuccessStatusCode(); } diff --git a/Govor.Console/Program.cs b/Govor.Console/Program.cs index e804006..2a622ef 100644 --- a/Govor.Console/Program.cs +++ b/Govor.Console/Program.cs @@ -25,8 +25,10 @@ namespace Govor.ConsoleClient { class Program { + //static string baseUrl = "https://govor-team-govor-88b3.twc1.net"; + static string baseUrl = "https://localhost:7155"; static string? AuthToken = null; - static HttpClientService HttpService = new("https://govor-team-govor-88b3.twc1.net"); // поменяй URL на свой + static HttpClientService HttpService = new(baseUrl); // поменяй URL на свой private static FriendsClient? friendsClient; static HubConnection? _hubConnection; static Dictionary> ChatHistory = new(); @@ -72,7 +74,7 @@ namespace Govor.ConsoleClient { AuthToken = await HttpService.LoginAsync(loginUsername, loginPassword); HttpClient sharedClient = new(); - sharedClient.BaseAddress = new Uri("https://govor-team-govor-88b3.twc1.net"); + sharedClient.BaseAddress = new Uri(baseUrl); sharedClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", AuthToken); friendsClient = new FriendsClient(sharedClient); @@ -98,7 +100,7 @@ namespace Govor.ConsoleClient { AuthToken = await HttpService.RegisterAsync(regUsername, regPassword, inviteCode); HttpClient sharedClient = new(); - sharedClient.BaseAddress = new Uri("https://govor-team-govor-88b3.twc1.net"); + sharedClient.BaseAddress = new Uri(baseUrl); sharedClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", AuthToken); friendsClient = new FriendsClient(sharedClient); @@ -165,7 +167,7 @@ namespace Govor.ConsoleClient Console.WriteLine("[Ошибка] Сначала войдите в систему."); break; } - Console.Write("Введите ID пользователя, чью заявку принимаете: "); + Console.Write("Введите ID заявки, которую хотите принять принимаете: "); var acceptId = Guid.Parse(Console.ReadLine()); await friendsClient.AcceptFriendRequestAsync(acceptId); Console.WriteLine("Принято"); @@ -179,7 +181,7 @@ namespace Govor.ConsoleClient var requests = await friendsClient.GetIncomingRequestsAsync(); foreach (var r in requests) { - Console.WriteLine($"Запрос от: {r.RequesterId} (добавьте через /accept {r.RequesterId})"); + Console.WriteLine($"Запрос от: {r.Requester.Username}| {r.RequesterId} | Был онлайн: {r.Requester.WasOnline} (добавьте через /accept {r.Id})"); } break; case "/chat": @@ -221,7 +223,7 @@ namespace Govor.ConsoleClient } _hubConnection = new HubConnectionBuilder() - .WithUrl("https://govor-team-govor-88b3.twc1.net/api/chats", options => + .WithUrl($"{baseUrl}/api/chats", options => { options.AccessTokenProvider = () => Task.FromResult(AuthToken); }) diff --git a/Govor.Contracts/DTOs/FriendshipDto.cs b/Govor.Contracts/DTOs/FriendshipDto.cs index 85065e1..f2b6ca1 100644 --- a/Govor.Contracts/DTOs/FriendshipDto.cs +++ b/Govor.Contracts/DTOs/FriendshipDto.cs @@ -8,4 +8,5 @@ public class FriendshipDto public Guid RequesterId { get; set; } public Guid AddresseeId { get; set; } public FriendshipStatus Status { get; set; } + public UserDto Requester { get; set; } } \ No newline at end of file From 66819a015a318ddb9ae5a150e00e5137eb8aea0e Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Sun, 6 Jul 2025 21:09:12 +0700 Subject: [PATCH 029/101] Refactor media attachments to use MediaFile entity This commit introduces the MediaFile entity and updates the media attachments model, repositories, and related logic to reference MediaFile instead of storing file metadata directly in MediaAttachments. Controller, service, and SignalR request/response contracts are updated to support the new structure. Database configurations and validators are also adjusted accordingly. This refactor improves media management and normalization in the data model. --- .../UnitTests/Services/FriendsServiceTests.cs | 8 ++--- Govor.API/Controllers/MediaController.cs | 18 ++++++------ Govor.API/Hubs/ChatsHub.cs | 26 ++++++++--------- .../Interfaces/Medias/IMediaService.cs | 14 +++++++++ .../Interfaces/Messages/IMessageService.cs | 4 +-- .../Messages/Parameters/SendMedia.cs | 2 +- Govor.Application/Services/FriendsService.cs | 3 +- Govor.Application/Services/MediaService.cs | 29 +++++++++++++++++++ Govor.Application/Services/MessageService.cs | 29 +++++++++---------- .../Requests/SignalR/EditMessageRequest.cs | 7 +++++ .../Requests/SignalR/MediaReference.cs | 2 -- .../Requests/SignalR/RemoveMessageRequest.cs | 6 ++++ .../Responses/SignalR/UserMessageResponse.cs | 2 +- .../Validators/MediaAttachmentsValidator.cs | 4 +-- Govor.Core/Models/MediaAttachments.cs | 14 +++------ Govor.Core/Models/MediaFile.cs | 10 +++++++ Govor.Core/Models/Message.cs | 1 - .../MediaAttachmentsConfiguration.cs | 19 +++++------- .../Configurations/MediaFileConfiguration.cs | 27 +++++++++++++++++ .../Configurations/MessagesConfiguration.cs | 5 ---- Govor.Data/GovorDbContext.cs | 2 ++ .../MediaAttachmentsRepository.cs | 9 ++---- Govor.Data/Repositories/MessagesRepository.cs | 18 +++++++----- 23 files changed, 166 insertions(+), 93 deletions(-) create mode 100644 Govor.Application/Interfaces/Medias/IMediaService.cs create mode 100644 Govor.Application/Services/MediaService.cs create mode 100644 Govor.Contracts/Requests/SignalR/EditMessageRequest.cs create mode 100644 Govor.Contracts/Requests/SignalR/RemoveMessageRequest.cs create mode 100644 Govor.Core/Models/MediaFile.cs create mode 100644 Govor.Data/Configurations/MediaFileConfiguration.cs diff --git a/Govor.API.Tests/UnitTests/Services/FriendsServiceTests.cs b/Govor.API.Tests/UnitTests/Services/FriendsServiceTests.cs index e1e77b7..79f6a6f 100644 --- a/Govor.API.Tests/UnitTests/Services/FriendsServiceTests.cs +++ b/Govor.API.Tests/UnitTests/Services/FriendsServiceTests.cs @@ -329,8 +329,8 @@ public class FriendsServiceTests f.Status = FriendshipStatus.Pending; }); - _usersRepositoryMock.Setup(f => f.FindByIdAsync(userId)) - .ReturnsAsync(user); + _friendshipsRepositoryMock.Setup(f => f.FindByUserIdAsync(userId)) + .ReturnsAsync(friendships); // Act var result = await _service.GetIncomingRequestsAsync(userId); @@ -346,8 +346,8 @@ public class FriendsServiceTests // Arrange var userId = Guid.NewGuid(); - _usersRepositoryMock - .Setup(r => r.FindByIdAsync(userId)) + _friendshipsRepositoryMock + .Setup(r => r.FindByUserIdAsync(userId)) .ThrowsAsync(new NotFoundByKeyException(userId)); // Act & Assert diff --git a/Govor.API/Controllers/MediaController.cs b/Govor.API/Controllers/MediaController.cs index 4aece1b..b7f00d8 100644 --- a/Govor.API/Controllers/MediaController.cs +++ b/Govor.API/Controllers/MediaController.cs @@ -1,4 +1,5 @@ using Govor.Application.Interfaces; +using Govor.Application.Interfaces.Medias; using Govor.Contracts.Requests; using Govor.Core.Models; using Govor.Core.Repositories.MediasAttachments; @@ -13,12 +14,12 @@ namespace Govor.API.Controllers; public class MediaController : Controller { private readonly ILogger _logger; - private readonly IStorageService _storageService; - - public MediaController(ILogger logger, IStorageService storageService) + private readonly IMediaService _mediaService; + + public MediaController(ILogger logger, IMediaService mediaService) { _logger = logger; - _storageService = storageService; + _mediaService = mediaService; } [HttpPost("upload")] @@ -27,11 +28,10 @@ public class MediaController : Controller { try { - var url = await _storageService.SaveAsync(request.Data,request.FileName); - var mediaId = Guid.NewGuid(); - - - return Ok(mediaId); + var result = await _mediaService.UploadMediaAsync(new Media(request.Data, request.FileName, request.Type, + request.MimeType, request.EncryptedKey)); + + return Ok(result); } catch (Exception ex) { diff --git a/Govor.API/Hubs/ChatsHub.cs b/Govor.API/Hubs/ChatsHub.cs index aad3501..8c5eb3e 100644 --- a/Govor.API/Hubs/ChatsHub.cs +++ b/Govor.API/Hubs/ChatsHub.cs @@ -92,14 +92,14 @@ public class ChatsHub : Hub RecipientType: request.RecipientType, SendAt: DateTime.UtcNow, Media: request.MediaAttachments?.Select(f => new SendMedia( - f.MediaId, f.EncryptedKey, f.Type, f.MimeType)) ?? Array.Empty() + f.MediaId, f.EncryptedKey)) ?? Array.Empty() ); try { var result = await _messageService.SendMessageAsync(sendMessageParams); - if (!result.IsSuccess || result.MessageId == Guid.Empty) + if (!result.IsSuccess || result.Message.Id == Guid.Empty) { _logger.LogError(result.Exception, "Failed to send message from {SenderId} to {RecipientId}. Error: {ErrorMessage}", senderId, request.RecipientId, result.Exception?.Message ?? "Unknown error"); if (result.Exception != null) throw result.Exception; @@ -108,14 +108,15 @@ public class ChatsHub : Hub var messageResponse = new UserMessageResponse // Assuming a response DTO { - MessageId = result.MessageId, - SenderId = senderId, - RecipientId = request.RecipientId, - RecipientType = request.RecipientType, - EncryptedContent = request.EncryptedContent, - SentAt = sendMessageParams.SendAt, + MessageId = result.Message.Id, + SenderId = result.Message.SenderId, + RecipientId = result.Message.RecipientId, + RecipientType = result.Message.RecipientType, + EncryptedContent = result.Message.EncryptedContent, + SentAt = result.Message.SentAt, IsEdited = false, - MediaAttachments = request.MediaAttachments, + MediaAttachments = result.Message.MediaAttachments + .Select(m => m.MediaFile).ToList(), ReplyToMessageId = request.ReplyToMessageId }; @@ -134,7 +135,7 @@ public class ChatsHub : Hub // Notify sender (confirmation) on their connection await Clients.Caller.SendAsync("MessageSent", messageResponse); // Or use "ReceiveMessage" if the sender should also just get it like anyone else - _logger.LogInformation("Message {MessageId} sent successfully from {SenderId} to {RecipientId} ({RecipientType})", result.MessageId, senderId, request.RecipientId, request.RecipientType); + _logger.LogInformation("Message {MessageId} sent successfully from {SenderId} to {RecipientId} ({RecipientType})", result.Message.Id, senderId, request.RecipientId, request.RecipientType); } catch (Exception ex) { @@ -145,18 +146,17 @@ public class ChatsHub : Hub } } - public async Task Remove(Guid recipientId, Guid messageId) + public async Task Remove(RemoveMessageRequest request) { } - public async Task Edit(string newMessage, Guid messageId) + public async Task Edit(EditMessageRequest request) { } - private Guid GetUserId(bool suppressException = false) { var userIdClaim = Context.User?.FindFirst("userId")?.Value; diff --git a/Govor.Application/Interfaces/Medias/IMediaService.cs b/Govor.Application/Interfaces/Medias/IMediaService.cs new file mode 100644 index 0000000..ec3e707 --- /dev/null +++ b/Govor.Application/Interfaces/Medias/IMediaService.cs @@ -0,0 +1,14 @@ +using Govor.Core.Models; + +namespace Govor.Application.Interfaces.Medias; + +public interface IMediaService +{ + public Task UploadMediaAsync(Media file); + public Task DeleteMediaAsync(Guid fileId); + public Task GetMediaAsync(string url); +} + +public record Media(byte[] Data, string FileName, MediaType Type, string MineType, string EncryptedKey); + +public record MediaUploadResult(Guid? MediaId, string Url); \ No newline at end of file diff --git a/Govor.Application/Interfaces/Messages/IMessageService.cs b/Govor.Application/Interfaces/Messages/IMessageService.cs index 850afcb..5493bbf 100644 --- a/Govor.Application/Interfaces/Messages/IMessageService.cs +++ b/Govor.Application/Interfaces/Messages/IMessageService.cs @@ -17,8 +17,8 @@ public interface IMessageService // Define specific result types for clarity, including original message for notifications if needed -public record SendMessageResult(bool IsSuccess, Exception? Exception, Guid MessageId) - : Result(IsSuccess, Exception, MessageId); +public record SendMessageResult(bool IsSuccess, Exception? Exception, Message Message) + : Result(IsSuccess, Exception, Message.Id); public record EditMessageResult(bool IsSuccess, Exception? Exception, Message? OriginalMessage) : Result(IsSuccess, Exception, OriginalMessage?.Id ?? Guid.Empty) diff --git a/Govor.Application/Interfaces/Messages/Parameters/SendMedia.cs b/Govor.Application/Interfaces/Messages/Parameters/SendMedia.cs index 1d87a71..e07b452 100644 --- a/Govor.Application/Interfaces/Messages/Parameters/SendMedia.cs +++ b/Govor.Application/Interfaces/Messages/Parameters/SendMedia.cs @@ -2,4 +2,4 @@ using Govor.Core.Models; namespace Govor.Application.Interfaces.Messages.Parameters; -public record SendMedia(Guid MediaId, string EncryptedKey, MediaType Type, string MimeType); \ No newline at end of file +public record SendMedia(Guid MediaId, string EncryptedKey); \ No newline at end of file diff --git a/Govor.Application/Services/FriendsService.cs b/Govor.Application/Services/FriendsService.cs index e24dcd4..45b0de8 100644 --- a/Govor.Application/Services/FriendsService.cs +++ b/Govor.Application/Services/FriendsService.cs @@ -105,7 +105,8 @@ public class FriendsService : IFriendsService try { var friendships = await _friendshipsRepository.FindByUserIdAsync(userId); - return friendships.Where(f => f.AddresseeId == userId && f.Status == FriendshipStatus.Pending).ToList(); + return friendships.Where(f => f.AddresseeId == userId && f.Status == FriendshipStatus.Pending).ToList() + ?? new List(); } catch (NotFoundByKeyException ex) { diff --git a/Govor.Application/Services/MediaService.cs b/Govor.Application/Services/MediaService.cs new file mode 100644 index 0000000..178c364 --- /dev/null +++ b/Govor.Application/Services/MediaService.cs @@ -0,0 +1,29 @@ +using Govor.Application.Interfaces; +using Govor.Application.Interfaces.Medias; + +namespace Govor.Application.Services; + +public class MediaService : IMediaService +{ + private IStorageService _storageService; + + public MediaService(IStorageService storageService) + { + _storageService = storageService; + } + + public Task UploadMediaAsync(Media file) + { + throw new NotImplementedException(); + } + + public Task DeleteMediaAsync(Guid fileId) + { + throw new NotImplementedException(); + } + + public Task GetMediaAsync(string url) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/Govor.Application/Services/MessageService.cs b/Govor.Application/Services/MessageService.cs index d423fdb..cf4469e 100644 --- a/Govor.Application/Services/MessageService.cs +++ b/Govor.Application/Services/MessageService.cs @@ -38,10 +38,10 @@ public class MessageService : IMessageService // Validate recipient if (sendParams.RecipientType == RecipientType.User) { - if (!await _usersRepository.ExistsByIdAsync(sendParams.RecipientId)) // Changed to ExistsByIdAsync + if (!await _usersRepository.ExistsByIdAsync(sendParams.RecipientId)) { _logger.LogWarning("Attempt to send message to non-existent user {RecipientId}", sendParams.RecipientId); - return new SendMessageResult(false, new KeyNotFoundException($"Recipient user {sendParams.RecipientId} not found."), Guid.Empty); + return new SendMessageResult(false, new KeyNotFoundException($"Recipient user {sendParams.RecipientId} not found."), default); } // Verify friendship for private messages await _verifyFriendship.VerifyAsync(sendParams.FromUserId, sendParams.RecipientId); @@ -51,7 +51,7 @@ public class MessageService : IMessageService if (!_groupsRepository.Exists(sendParams.RecipientId)) { _logger.LogWarning("Attempt to send message to non-existent group {GroupId}", sendParams.RecipientId); - return new SendMessageResult(false, new KeyNotFoundException($"Recipient group {sendParams.RecipientId} not found."), Guid.Empty); + return new SendMessageResult(false, new KeyNotFoundException($"Recipient group {sendParams.RecipientId} not found."), default); } // TODO: Optionally, verify if sender is a member of the group // bool isMember = await _groupsRepository.IsUserMemberOfGroupAsync(sendParams.FromUserId, sendParams.RecipientId); @@ -64,7 +64,7 @@ public class MessageService : IMessageService else { _logger.LogError("Invalid recipient type specified: {RecipientType}", sendParams.RecipientType); - return new SendMessageResult(false, new ArgumentException("Invalid recipient type."), Guid.Empty); + return new SendMessageResult(false, new ArgumentException("Invalid recipient type."), default); } var messageId = Guid.NewGuid(); @@ -80,23 +80,20 @@ public class MessageService : IMessageService ReplyToMessageId = sendParams.ReplyToMessageId, MediaAttachments = sendParams.Media?.Select(m => new MediaAttachments { - Id = m.MediaId, // Assuming SendMedia provides Id or it's generated here/by repo + Id = Guid.NewGuid(), MessageId = messageId, - Type = m.Type, - MimeType = m.MimeType, - EncryptedKey = m.EncryptedKey, - // FilePath will be set by storage service or handled by repository if media ID is pre-generated + MediaFileId = m.MediaId }).ToList() ?? new List() }; await _messagesRepository.AddAsync(message); _logger.LogInformation("Message {MessageId} from {SenderId} to {RecipientId} ({RecipientType}) saved successfully.", messageId, sendParams.FromUserId, sendParams.RecipientId, sendParams.RecipientType); - return new SendMessageResult(true, null, messageId); + return new SendMessageResult(true, null, message); } catch (Exception ex) { _logger.LogError(ex, "Error sending message from {SenderId} to {RecipientId} ({RecipientType})", sendParams.FromUserId, sendParams.RecipientId, sendParams.RecipientType); - return new SendMessageResult(false, ex, Guid.Empty); + return new SendMessageResult(false, ex, default); } } @@ -133,12 +130,12 @@ public class MessageService : IMessageService await _messagesRepository.UpdateAsync(message); _logger.LogInformation("Message {MessageId} edited successfully by user {EditorId}", editParams.MessageId, editParams.EditorId); - return new EditMessageResult(true, null, originalMessageForNotification); + return new EditMessageResult(true, default, originalMessageForNotification); } catch (Exception ex) { _logger.LogError(ex, "Error editing message {MessageId} by user {EditorId}", editParams.MessageId, editParams.EditorId); - return new EditMessageResult(false, ex, null); + return new EditMessageResult(false, ex, default); } } @@ -159,7 +156,7 @@ public class MessageService : IMessageService // } // } else { _logger.LogWarning("User {DeleterId} attempted to delete message {MessageId} not sent by them (sender was {SenderId})", deleteParams.DeleterId, deleteParams.MessageId, message.SenderId); - return new DeleteMessageResult(false, new UnauthorizedAccessException("User is not authorized to delete this message."), null); + return new DeleteMessageResult(false, new UnauthorizedAccessException("User is not authorized to delete this message."), default); // } } @@ -174,12 +171,12 @@ public class MessageService : IMessageService await _messagesRepository.RemoveAsync(deleteParams.MessageId); _logger.LogInformation("Message {MessageId} deleted successfully by user {DeleterId}", deleteParams.MessageId, deleteParams.DeleterId); - return new DeleteMessageResult(true, null, originalMessageForNotification); + return new DeleteMessageResult(true, default, originalMessageForNotification); } catch (Exception ex) { _logger.LogError(ex, "Error deleting message {MessageId} by user {DeleterId}", deleteParams.MessageId, deleteParams.DeleterId); - return new DeleteMessageResult(false, ex, null); + return new DeleteMessageResult(false, ex, default); } } } \ No newline at end of file diff --git a/Govor.Contracts/Requests/SignalR/EditMessageRequest.cs b/Govor.Contracts/Requests/SignalR/EditMessageRequest.cs new file mode 100644 index 0000000..6d8fc70 --- /dev/null +++ b/Govor.Contracts/Requests/SignalR/EditMessageRequest.cs @@ -0,0 +1,7 @@ +namespace Govor.Contracts.Requests.SignalR; + +public class EditMessageRequest +{ + public Guid MessageId { get; set; } + public string NewEncryptedContent { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/Govor.Contracts/Requests/SignalR/MediaReference.cs b/Govor.Contracts/Requests/SignalR/MediaReference.cs index f5faef9..d2b9a1b 100644 --- a/Govor.Contracts/Requests/SignalR/MediaReference.cs +++ b/Govor.Contracts/Requests/SignalR/MediaReference.cs @@ -6,6 +6,4 @@ public record MediaReference { public Guid MediaId { get; init; } public string EncryptedKey { get; init; } = string.Empty; - public MediaType Type { get; init; } - public string MimeType { get; init; } = string.Empty; } \ No newline at end of file diff --git a/Govor.Contracts/Requests/SignalR/RemoveMessageRequest.cs b/Govor.Contracts/Requests/SignalR/RemoveMessageRequest.cs new file mode 100644 index 0000000..4948b75 --- /dev/null +++ b/Govor.Contracts/Requests/SignalR/RemoveMessageRequest.cs @@ -0,0 +1,6 @@ +namespace Govor.Contracts.Requests.SignalR; + +public class RemoveMessageRequest +{ + public Guid MessageId { get; set; } +} \ No newline at end of file diff --git a/Govor.Contracts/Responses/SignalR/UserMessageResponse.cs b/Govor.Contracts/Responses/SignalR/UserMessageResponse.cs index 6380a05..20a33fd 100644 --- a/Govor.Contracts/Responses/SignalR/UserMessageResponse.cs +++ b/Govor.Contracts/Responses/SignalR/UserMessageResponse.cs @@ -13,5 +13,5 @@ public record UserMessageResponse public Guid? ReplyToMessageId { get; init; } public DateTime SentAt { get; init; } public bool IsEdited { get; init; } = false; - public List MediaAttachments { get; init; } = new List(); + public List MediaAttachments { get; init; } = new List(); } \ No newline at end of file diff --git a/Govor.Core/Infrastructure/Validators/MediaAttachmentsValidator.cs b/Govor.Core/Infrastructure/Validators/MediaAttachmentsValidator.cs index fc20e82..aa598f7 100644 --- a/Govor.Core/Infrastructure/Validators/MediaAttachmentsValidator.cs +++ b/Govor.Core/Infrastructure/Validators/MediaAttachmentsValidator.cs @@ -15,10 +15,8 @@ public class MediaAttachmentsValidator : IObjectValidator throw new ArgumentException("Id cannot be empty", nameof(attachments)); if(attachments.MessageId == Guid.Empty) throw new ArgumentException("MessageId cannot be empty", nameof(attachments)); - if(string.IsNullOrWhiteSpace(attachments.FilePath)) + if(string.IsNullOrWhiteSpace(attachments.MediaFile.Url)) throw new ArgumentException("File path cannot be empty", nameof(attachments)); - if(string.IsNullOrWhiteSpace(attachments.EncryptedKey)) - throw new ArgumentException("Encrypted key cannot be empty", nameof(attachments)); } catch (Exception ex) { diff --git a/Govor.Core/Models/MediaAttachments.cs b/Govor.Core/Models/MediaAttachments.cs index 43da672..947d458 100644 --- a/Govor.Core/Models/MediaAttachments.cs +++ b/Govor.Core/Models/MediaAttachments.cs @@ -4,22 +4,16 @@ public class MediaAttachments { public Guid Id { get; set; } public Guid MessageId { get; set; } - public MediaType Type { get; set; } - public string FilePath { get; set; } = string.Empty; // local path in filesystem - public string MimeType { get; set; } = string.Empty; - public string? EncryptedKey { get; set; } - public Message Message { get; set; } = null!; - + public Guid MediaFileId { get; set; } + public Message Message { get; set; } + public MediaFile MediaFile { get; set; } public override bool Equals(object? obj) { if (obj is not MediaAttachments other) return false; return Id == other.Id && MessageId == other.MessageId && - EncryptedKey == other.EncryptedKey && - Type == other.Type && - FilePath == other.FilePath && - MimeType == other.MimeType; + MediaFileId == other.MediaFileId; } } diff --git a/Govor.Core/Models/MediaFile.cs b/Govor.Core/Models/MediaFile.cs new file mode 100644 index 0000000..038f00f --- /dev/null +++ b/Govor.Core/Models/MediaFile.cs @@ -0,0 +1,10 @@ +namespace Govor.Core.Models; + +public class MediaFile +{ + public Guid Id { get; set; } + public string Url { get; set; } + public MediaType MediaType { get; set; } + public string MineType { get; set; } + public DateTime DateCreated { get; set; } +} \ No newline at end of file diff --git a/Govor.Core/Models/Message.cs b/Govor.Core/Models/Message.cs index b1bac31..6b72852 100644 --- a/Govor.Core/Models/Message.cs +++ b/Govor.Core/Models/Message.cs @@ -15,7 +15,6 @@ public class Message public List MessageViews { get; set; } = new List(); public Guid? ReplyToMessageId { get; set; } - public Message? ReplyToMessage { get; set; } // navigation public override bool Equals(object? obj) { diff --git a/Govor.Data/Configurations/MediaAttachmentsConfiguration.cs b/Govor.Data/Configurations/MediaAttachmentsConfiguration.cs index d9c6bc4..b0ad220 100644 --- a/Govor.Data/Configurations/MediaAttachmentsConfiguration.cs +++ b/Govor.Data/Configurations/MediaAttachmentsConfiguration.cs @@ -10,17 +10,14 @@ public class MediaAttachmentsConfiguration : IEntityTypeConfiguration ma.Id); - builder.Property(ma => ma.FilePath) - .IsRequired(); + builder.HasOne(ma => ma.Message) + .WithMany(m => m.MediaAttachments) + .HasForeignKey(ma => ma.MessageId) + .OnDelete(DeleteBehavior.Cascade); - builder.Property(ma => ma.MimeType) - .IsRequired(); - - builder.Property(ma => ma.EncryptedKey) - .HasMaxLength(512); // зависит от шифра - - builder.Property(ma => ma.Type) - .HasConversion() // enum as string (e.g., "Image") - .IsRequired(); + builder.HasOne(ma => ma.MediaFile) + .WithMany() + .HasForeignKey(ma => ma.MediaFileId) + .OnDelete(DeleteBehavior.Restrict); } } \ No newline at end of file diff --git a/Govor.Data/Configurations/MediaFileConfiguration.cs b/Govor.Data/Configurations/MediaFileConfiguration.cs new file mode 100644 index 0000000..452587f --- /dev/null +++ b/Govor.Data/Configurations/MediaFileConfiguration.cs @@ -0,0 +1,27 @@ +using Govor.Core.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Govor.Data.Configurations; + +public class MediaFileConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(ma => ma.Id); + + builder.Property(mf => mf.Url) + .IsRequired(); + + builder.Property(mf => mf.MediaType) + .HasConversion() // enum as string (e.g., "Image") + .IsRequired(); + + builder.Property(ma => ma.MineType) + .HasMaxLength(128) + .IsRequired(); + + builder.Property(mf => mf.DateCreated) + .IsRequired(); + } +} \ No newline at end of file diff --git a/Govor.Data/Configurations/MessagesConfiguration.cs b/Govor.Data/Configurations/MessagesConfiguration.cs index 39f8adb..f1a973d 100644 --- a/Govor.Data/Configurations/MessagesConfiguration.cs +++ b/Govor.Data/Configurations/MessagesConfiguration.cs @@ -25,11 +25,6 @@ public class MessagesConfiguration : IEntityTypeConfiguration .HasForeignKey(mv => mv.MessageId) .OnDelete(DeleteBehavior.Cascade); - builder.HasOne(m => m.ReplyToMessage) - .WithMany() - .HasForeignKey(m => m.ReplyToMessageId) - .OnDelete(DeleteBehavior.Restrict); - builder.Property(m => m.EncryptedContent) .IsRequired(); } diff --git a/Govor.Data/GovorDbContext.cs b/Govor.Data/GovorDbContext.cs index dea43e1..c07d72a 100644 --- a/Govor.Data/GovorDbContext.cs +++ b/Govor.Data/GovorDbContext.cs @@ -18,6 +18,7 @@ public class GovorDbContext(DbContextOptions options) : DbContex public virtual DbSet MessageViews { get; set; } public virtual DbSet MessageReactions { get; set; } public virtual DbSet MediaAttachments { get; set; } + public virtual DbSet MediaFiles { get; set; } public virtual DbSet ChatGroups { get; set; } public virtual DbSet GroupMemberships { get; set; } @@ -33,6 +34,7 @@ public class GovorDbContext(DbContextOptions options) : DbContex modelBuilder.ApplyConfiguration(new MessageReactionConfiguration()); modelBuilder.ApplyConfiguration(new MediaAttachmentsConfiguration()); modelBuilder.ApplyConfiguration(new MessageViewConfiguration()); + modelBuilder.ApplyConfiguration(new MediaFileConfiguration()); base.OnModelCreating(modelBuilder); } diff --git a/Govor.Data/Repositories/MediaAttachmentsRepository.cs b/Govor.Data/Repositories/MediaAttachmentsRepository.cs index 391c18c..e60a3b4 100644 --- a/Govor.Data/Repositories/MediaAttachmentsRepository.cs +++ b/Govor.Data/Repositories/MediaAttachmentsRepository.cs @@ -77,9 +77,7 @@ public class MediaAttachmentsRepository : IMediaAttachmentsRepository .ExecuteUpdateAsync(u => u .SetProperty(m => m.MessageId, attachments.MessageId) .SetProperty(m => m.Message, attachments.Message) - .SetProperty(m => m.FilePath, attachments.FilePath) - .SetProperty(m => m.MimeType, attachments.MimeType) - .SetProperty(m => m.EncryptedKey, attachments.EncryptedKey) + .SetProperty(m => m.MediaFileId, attachments.MediaFileId) ); if (rowsAffected == 0) @@ -121,11 +119,8 @@ public class MediaAttachmentsRepository : IMediaAttachmentsRepository return _context.MediaAttachments.Any( e => e.Id == attachments.Id && - e.EncryptedKey == attachments.EncryptedKey && - e.MimeType == attachments.MimeType && - e.FilePath == attachments.FilePath && e.MessageId == attachments.MessageId && - e.Type == attachments.Type + e.MediaFileId == attachments.MediaFileId ); } } \ No newline at end of file diff --git a/Govor.Data/Repositories/MessagesRepository.cs b/Govor.Data/Repositories/MessagesRepository.cs index 2ef4755..a6c0a16 100644 --- a/Govor.Data/Repositories/MessagesRepository.cs +++ b/Govor.Data/Repositories/MessagesRepository.cs @@ -21,7 +21,8 @@ public class MessagesRepository : IMessagesRepository { return await _context.Messages .AsNoTracking() - .Include(m => m.ReplyToMessage) + .Include(m => m.MediaAttachments) + .ThenInclude(m => m.MediaFile) .AsSplitQuery() .ToListOrThrowIfEmpty(new NotFoundException("No messages found in the database")); } @@ -30,7 +31,6 @@ public class MessagesRepository : IMessagesRepository { return await _context.Messages .AsNoTracking() - .Include(m => m.ReplyToMessage) .AsSplitQuery() .FirstOrDefaultAsync(m => m.Id == messageId) ?? throw new NotFoundByKeyException(messageId, "Message with given id does not exist"); @@ -40,7 +40,8 @@ public class MessagesRepository : IMessagesRepository { return await _context.Messages .AsNoTracking() - .Include(m => m.ReplyToMessage) + .Include(m => m.MediaAttachments) + .ThenInclude(m => m.MediaFile) .AsSplitQuery() .Where(m => m.SenderId == senderId) .ToListOrThrowIfEmpty(new NotFoundByKeyException(senderId, "Messages with given sender id do not exist")); @@ -50,7 +51,8 @@ public class MessagesRepository : IMessagesRepository { return await _context.Messages .AsNoTracking() - .Include(m => m.ReplyToMessage) + .Include(m => m.MediaAttachments) + .ThenInclude(m => m.MediaFile) .AsSplitQuery() .Where(m => m.RecipientId == receiverId) .ToListOrThrowIfEmpty(new NotFoundByKeyException(receiverId, "Messages with given recipient id do not exist")); @@ -60,7 +62,8 @@ public class MessagesRepository : IMessagesRepository { return await _context.Messages .AsNoTracking() - .Include(m => m.ReplyToMessage) + .Include(m => m.MediaAttachments) + .ThenInclude(m => m.MediaFile) .AsSplitQuery() .Where(m => m.SenderId == senderId && m.RecipientId == receiverId @@ -72,7 +75,8 @@ public class MessagesRepository : IMessagesRepository { return await _context.Messages .AsNoTracking() - .Include(m => m.ReplyToMessage) + .Include(m => m.MediaAttachments) + .ThenInclude(m => m.MediaFile) .AsSplitQuery() .Where(m => m.SentAt == date) .ToListOrThrowIfEmpty(new NotFoundByKeyException(date, "Messages sent at date do not exist")); @@ -161,7 +165,7 @@ public class MessagesRepository : IMessagesRepository m.EditedAt == message.EditedAt && m.IsEdited == message.IsEdited && m.SentAt == message.SentAt && - m.ReplyToMessageId == message.ReplyToMessageId + m.ReplyToMessageId == message.ReplyToMessageId ); } From 2cb6a93060dd38e58d354aa1f67899dc1d5129df Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Mon, 7 Jul 2025 13:55:29 +0700 Subject: [PATCH 030/101] Add private chat support and group model enhancements Introduces repositories, validators, and integration tests for private chats. Refactors group-related models to support invitations and memberships, updates group repository interfaces and implementations, and enhances message service logic to handle private chat creation and retrieval. Also registers new services and validators in DI, and updates related tests. --- .../PrivateChatsRepositoryTests.cs | 7 + .../IntegrationTests/Hubs/ChatsHubTests.cs | 35 ++++ .../Validators/PrivateChatValidatorTests.cs | 7 + .../UnitTests/Services/MessageServiceTests.cs | 15 +- .../ConfigurationProgramExtensions.cs | 3 + .../Interfaces/Messages/IMessageService.cs | 2 +- Govor.Application/Services/MessageService.cs | 46 +++++- .../Validators/PrivateChatValidator.cs | 38 +++++ Govor.Core/Models/ChatGroup.cs | 6 +- Govor.Core/Models/GroupInvitation.cs | 15 ++ Govor.Core/Models/GroupMembership.cs | 1 + .../Groups/IGroupMessagesReader.cs | 8 + .../Repositories/Groups/IGroupsReader.cs | 14 +- .../Repositories/Groups/IGroupsWriter.cs | 8 +- .../PrivateChats/IPrivateChatsExist.cs | 7 + .../PrivateChats/IPrivateChatsReader.cs | 10 ++ .../PrivateChats/IPrivateChatsRepository.cs | 6 + .../PrivateChats/IPrivateChatsWriter.cs | 10 ++ Govor.Data/GovorDbContext.cs | 1 + Govor.Data/Repositories/GroupRepository.cs | 154 +++++++++++++++--- .../Repositories/PrivateChatsRepository.cs | 115 +++++++++++++ 21 files changed, 454 insertions(+), 54 deletions(-) create mode 100644 Govor.API.Tests/IntegrationTests/EF/Repositories/PrivateChatsRepositoryTests.cs create mode 100644 Govor.API.Tests/IntegrationTests/Hubs/ChatsHubTests.cs create mode 100644 Govor.API.Tests/UnitTests/Infrastructure/Validators/PrivateChatValidatorTests.cs create mode 100644 Govor.Core/Infrastructure/Validators/PrivateChatValidator.cs create mode 100644 Govor.Core/Models/GroupInvitation.cs create mode 100644 Govor.Core/Repositories/Groups/IGroupMessagesReader.cs create mode 100644 Govor.Core/Repositories/PrivateChats/IPrivateChatsExist.cs create mode 100644 Govor.Core/Repositories/PrivateChats/IPrivateChatsReader.cs create mode 100644 Govor.Core/Repositories/PrivateChats/IPrivateChatsRepository.cs create mode 100644 Govor.Core/Repositories/PrivateChats/IPrivateChatsWriter.cs create mode 100644 Govor.Data/Repositories/PrivateChatsRepository.cs diff --git a/Govor.API.Tests/IntegrationTests/EF/Repositories/PrivateChatsRepositoryTests.cs b/Govor.API.Tests/IntegrationTests/EF/Repositories/PrivateChatsRepositoryTests.cs new file mode 100644 index 0000000..41467ef --- /dev/null +++ b/Govor.API.Tests/IntegrationTests/EF/Repositories/PrivateChatsRepositoryTests.cs @@ -0,0 +1,7 @@ +namespace Govor.API.Tests.IntegrationTests.EF.Repositories; + +[TestFixture] +public class PrivateChatsRepositoryTests +{ + +} \ No newline at end of file diff --git a/Govor.API.Tests/IntegrationTests/Hubs/ChatsHubTests.cs b/Govor.API.Tests/IntegrationTests/Hubs/ChatsHubTests.cs new file mode 100644 index 0000000..4f2ebe1 --- /dev/null +++ b/Govor.API.Tests/IntegrationTests/Hubs/ChatsHubTests.cs @@ -0,0 +1,35 @@ +using AutoFixture; +using Govor.API.Hubs; +using Govor.Application.Interfaces.Messages; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Govor.API.Tests.IntegrationTests.Hubs; + +[TestFixture] +public class ChatsHubTests +{ + private Mock> _loggerMock; + private Mock _messageServiceMock; + private Fixture _fixture; + private ChatsHub _chatsHub; + + [SetUp] + public void SetUp() + { + _fixture = new Fixture(); + _fixture.Behaviors.OfType().ToList().ForEach(b => _fixture.Behaviors.Remove(b)); + _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); + + _messageServiceMock = new Mock(); + _loggerMock = new Mock>(); + + _chatsHub = new ChatsHub( + _loggerMock.Object, + _messageServiceMock.Object + ); + } + + // Test for Send action + +} \ No newline at end of file diff --git a/Govor.API.Tests/UnitTests/Infrastructure/Validators/PrivateChatValidatorTests.cs b/Govor.API.Tests/UnitTests/Infrastructure/Validators/PrivateChatValidatorTests.cs new file mode 100644 index 0000000..8abd1fd --- /dev/null +++ b/Govor.API.Tests/UnitTests/Infrastructure/Validators/PrivateChatValidatorTests.cs @@ -0,0 +1,7 @@ +namespace Govor.API.Tests.UnitTests.Infrastructure.Validators; + +[TestFixture] +public class PrivateChatValidatorTests +{ + +} \ No newline at end of file diff --git a/Govor.API.Tests/UnitTests/Services/MessageServiceTests.cs b/Govor.API.Tests/UnitTests/Services/MessageServiceTests.cs index d789064..3260d5b 100644 --- a/Govor.API.Tests/UnitTests/Services/MessageServiceTests.cs +++ b/Govor.API.Tests/UnitTests/Services/MessageServiceTests.cs @@ -5,6 +5,7 @@ using Govor.Application.Services; using Govor.Core.Models; using Govor.Core.Repositories.Groups; using Govor.Core.Repositories.Messages; +using Govor.Core.Repositories.PrivateChats; using Govor.Core.Repositories.Users; using Govor.Data.Repositories.Exceptions; using Microsoft.Extensions.Logging; @@ -19,6 +20,7 @@ public class MessageServiceTests private Mock _mockUsersRepo; private Mock _mockGroupsRepo; private Mock _mockVerifyFriendship; + private Mock _mockPrivateChats; private Mock> _mockLogger; private MessageService _messageService; @@ -29,6 +31,7 @@ public class MessageServiceTests _mockUsersRepo = new Mock(); _mockGroupsRepo = new Mock(); _mockVerifyFriendship = new Mock(); + _mockPrivateChats = new Mock(); _mockLogger = new Mock>(); _messageService = new MessageService( @@ -36,6 +39,7 @@ public class MessageServiceTests _mockUsersRepo.Object, _mockGroupsRepo.Object, _mockVerifyFriendship.Object, + _mockPrivateChats.Object, _mockLogger.Object); } @@ -59,7 +63,8 @@ public class MessageServiceTests _mockUsersRepo.Setup(r => r.ExistsByIdAsync(recipientId)).ReturnsAsync(true); _mockVerifyFriendship.Setup(v => v.VerifyAsync(senderId, recipientId)).Returns(Task.CompletedTask); _mockMessagesRepo.Setup(r => r.AddAsync(It.IsAny())).Returns(Task.CompletedTask); - + _mockPrivateChats.Setup(c => c.Exist(senderId, recipientId)).Returns(true); + _mockPrivateChats.Setup(c => c.GetByMembersAsync(senderId, recipientId)).ReturnsAsync(new PrivateChat(){Id = recipientId}); // Act var result = await _messageService.SendMessageAsync(sendMessageParams); // Assert @@ -90,7 +95,7 @@ public class MessageServiceTests new List()); _mockGroupsRepo.Setup(r => r.Exists(groupId)).Returns(true); - _mockGroupsRepo.Setup(r => r.IsUserMemberOfGroupAsync(senderId, groupId)).Returns(true); + _mockGroupsRepo.Setup(r => r.IsUserMemberOfGroupAsync(senderId, groupId)).ReturnsAsync(true); _mockMessagesRepo.Setup(r => r.AddAsync(It.IsAny())).Returns(Task.CompletedTask); // Act @@ -130,7 +135,7 @@ public class MessageServiceTests Assert.That(result.IsSuccess, Is.EqualTo(false)); Assert.That(result.Exception, Is.Not.Null); Assert.That(result.Exception,Is.TypeOf()); - Assert.That(result.MessageId, Is.EqualTo(Guid.Empty)); + Assert.That(result.Message, Is.Null); } [Test] @@ -160,9 +165,11 @@ public class MessageServiceTests Assert.That(result.IsSuccess, Is.EqualTo(false)); Assert.That(result.Exception, Is.Not.Null); Assert.That(result.Exception,Is.TypeOf()); - Assert.That(result.MessageId, Is.EqualTo(Guid.Empty)); + Assert.That(result.Message, Is.Null); } + + // Test for EditMessageAsync action [Test] public async Task EditMessageAsync_Success() diff --git a/Govor.API/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Extensions/ConfigurationProgramExtensions.cs index 44d19da..7e45361 100644 --- a/Govor.API/Extensions/ConfigurationProgramExtensions.cs +++ b/Govor.API/Extensions/ConfigurationProgramExtensions.cs @@ -17,6 +17,7 @@ using Govor.Core.Repositories.Groups; using Govor.Core.Repositories.Invaites; using Govor.Core.Repositories.MediasAttachments; using Govor.Core.Repositories.Messages; +using Govor.Core.Repositories.PrivateChats; using Govor.Core.Repositories.Users; using Govor.Data; using Govor.Data.Repositories; @@ -61,6 +62,7 @@ public static class ConfigurationProgramExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); } @@ -72,6 +74,7 @@ public static class ConfigurationProgramExtensions services.AddScoped, AdminValidator>(); services.AddScoped, InvitationValidator>(); services.AddScoped, FriendshipValidator>(); + services.AddScoped, PrivateChatValidator>(); } public static void AddGovorDbContext(this IServiceCollection services, IConfiguration configuration) diff --git a/Govor.Application/Interfaces/Messages/IMessageService.cs b/Govor.Application/Interfaces/Messages/IMessageService.cs index 5493bbf..f4c269e 100644 --- a/Govor.Application/Interfaces/Messages/IMessageService.cs +++ b/Govor.Application/Interfaces/Messages/IMessageService.cs @@ -18,7 +18,7 @@ public interface IMessageService // Define specific result types for clarity, including original message for notifications if needed public record SendMessageResult(bool IsSuccess, Exception? Exception, Message Message) - : Result(IsSuccess, Exception, Message.Id); + : Result(IsSuccess, Exception, Message?.Id ?? Guid.Empty); public record EditMessageResult(bool IsSuccess, Exception? Exception, Message? OriginalMessage) : Result(IsSuccess, Exception, OriginalMessage?.Id ?? Guid.Empty) diff --git a/Govor.Application/Services/MessageService.cs b/Govor.Application/Services/MessageService.cs index cf4469e..f894666 100644 --- a/Govor.Application/Services/MessageService.cs +++ b/Govor.Application/Services/MessageService.cs @@ -4,6 +4,7 @@ using Govor.Application.Interfaces.Messages.Parameters; using Govor.Core.Models; using Govor.Core.Repositories.Groups; using Govor.Core.Repositories.Messages; +using Govor.Core.Repositories.PrivateChats; using Govor.Core.Repositories.Users; using Microsoft.Extensions.Logging; @@ -14,6 +15,7 @@ public class MessageService : IMessageService private readonly IMessagesRepository _messagesRepository; private readonly IUsersRepository _usersRepository; // For validating user recipients private readonly IGroupsRepository _groupsRepository; // For validating group recipients and fetching members + private readonly IPrivateChatsRepository _privateChats; private readonly IVerifyFriendship _verifyFriendship; // For private messages private readonly ILogger _logger; @@ -22,17 +24,20 @@ public class MessageService : IMessageService IUsersRepository usersRepository, IGroupsRepository groupsRepository, IVerifyFriendship verifyFriendship, + IPrivateChatsRepository privateChats, ILogger logger) { _messagesRepository = messagesRepository; _usersRepository = usersRepository; _groupsRepository = groupsRepository; _verifyFriendship = verifyFriendship; + _privateChats = privateChats; _logger = logger; } public async Task SendMessageAsync(SendMessage sendParams) { + Guid recipientId; try { // Validate recipient @@ -45,6 +50,7 @@ public class MessageService : IMessageService } // Verify friendship for private messages await _verifyFriendship.VerifyAsync(sendParams.FromUserId, sendParams.RecipientId); + recipientId = await GetPrivateChatsIdAsync(sendParams.FromUserId, sendParams.RecipientId); } else if (sendParams.RecipientType == RecipientType.Group) { @@ -54,25 +60,28 @@ public class MessageService : IMessageService return new SendMessageResult(false, new KeyNotFoundException($"Recipient group {sendParams.RecipientId} not found."), default); } // TODO: Optionally, verify if sender is a member of the group - // bool isMember = await _groupsRepository.IsUserMemberOfGroupAsync(sendParams.FromUserId, sendParams.RecipientId); - // if (!isMember) - // { - // _logger.LogWarning("User {UserId} attempted to send message to group {GroupId} but is not a member", sendParams.FromUserId, sendParams.RecipientId); - // return new SendMessageResult(false, new UnauthorizedAccessException("Sender is not a member of the group."), Guid.Empty); - // } + bool isMember = await _groupsRepository.IsUserMemberOfGroupAsync(sendParams.FromUserId, sendParams.RecipientId); + if (!isMember) + { + _logger.LogWarning("User {UserId} attempted to send message to group {GroupId} but is not a member", sendParams.FromUserId, sendParams.RecipientId); + return new SendMessageResult(false, new UnauthorizedAccessException("Sender is not a member of the group."), default); + } + + recipientId = sendParams.RecipientId; } else { _logger.LogError("Invalid recipient type specified: {RecipientType}", sendParams.RecipientType); return new SendMessageResult(false, new ArgumentException("Invalid recipient type."), default); } - + + var messageId = Guid.NewGuid(); var message = new Message { Id = messageId, SenderId = sendParams.FromUserId, - RecipientId = sendParams.RecipientId, + RecipientId = recipientId, RecipientType = sendParams.RecipientType, EncryptedContent = sendParams.EncryptContent, SentAt = sendParams.SendAt, @@ -97,6 +106,24 @@ public class MessageService : IMessageService } } + private async Task GetPrivateChatsIdAsync(Guid userA, Guid userB) + { + Guid recipientId; + // Makes new PrivateChat if not exist + if (!_privateChats.Exist(userA, userB)) + { + recipientId = Guid.NewGuid(); + await _privateChats.AddAsync(new PrivateChat() + { Id = recipientId, UserAId = userA, UserBId = userB }); + } + else + { + recipientId = (await _privateChats.GetByMembersAsync(userA, userB)).Id; + } + + return recipientId; + } + public async Task EditMessageAsync(EditMessage editParams) { try @@ -109,7 +136,8 @@ public class MessageService : IMessageService return new EditMessageResult(false, new UnauthorizedAccessException("User is not authorized to edit this message."), null); } - // TODO: Add a time limit for editing messages? e.g., if (message.SentAt < DateTime.UtcNow.AddMinutes(-15)) throw new Exception("Edit time limit exceeded"); + /*if (message.SentAt < DateTime.UtcNow.AddMinutes(-15)) + throw new Exception("Edit time limit exceeded");*/ var originalMessageForNotification = new Message { diff --git a/Govor.Core/Infrastructure/Validators/PrivateChatValidator.cs b/Govor.Core/Infrastructure/Validators/PrivateChatValidator.cs new file mode 100644 index 0000000..184d442 --- /dev/null +++ b/Govor.Core/Infrastructure/Validators/PrivateChatValidator.cs @@ -0,0 +1,38 @@ +using Govor.Core.Models; + +namespace Govor.Core.Infrastructure.Validators; + +public class PrivateChatValidator : IObjectValidator +{ + public void Validate(PrivateChat chat) + { + try + { + if(chat is null) + throw new ArgumentNullException(nameof(chat)); + if(chat.Id == Guid.Empty) + throw new ArgumentException("Id cannot be empty", nameof(chat)); + if(chat.UserAId == Guid.Empty) + throw new ArgumentException("UserAId cannot be empty", nameof(chat.UserAId)); + if(chat.UserBId == Guid.Empty) + throw new ArgumentException("UserBId cannot be empty", nameof(chat.UserBId)); + } + catch (Exception ex) + { + throw new InvalidObjectException(ex); + } + } + + public bool TryValidate(PrivateChat chat) + { + try + { + Validate(chat); + return true; + } + catch (Exception ex) + { + return false; + } + } +} \ No newline at end of file diff --git a/Govor.Core/Models/ChatGroup.cs b/Govor.Core/Models/ChatGroup.cs index b1ae3c2..7954c97 100644 --- a/Govor.Core/Models/ChatGroup.cs +++ b/Govor.Core/Models/ChatGroup.cs @@ -6,9 +6,9 @@ public class ChatGroup public string Name { get; set; } public string Description { get; set; } public Guid ImageId { get; set; } - - public List InviteCode { get; set; } public bool IsChannel { get; set; } public bool IsPrivate { get; set; } - public List Admins { get; set; } = new(); + public List Admins { get; set; } = new(); + public List Members { get; set; } = new(); + public List InviteCodes { get; set; } = new(); } \ No newline at end of file diff --git a/Govor.Core/Models/GroupInvitation.cs b/Govor.Core/Models/GroupInvitation.cs new file mode 100644 index 0000000..57f44c9 --- /dev/null +++ b/Govor.Core/Models/GroupInvitation.cs @@ -0,0 +1,15 @@ +namespace Govor.Core.Models; + +public class GroupInvitation +{ + public Guid Id { get; set; } + public Guid GroupId { get; set; } + public Guid UserMakerId { get; set; } + public string InvitationCode { get; set; } + public string Description {get; set;} + public DateTime EndDate { get; set; } + public DateTime CreatedAt { get; set; } + public int MaxParticipants { get; set; } + public List GroupMemberships { get; set; } = new(); + public User? UserMaker { get; set; } +} \ No newline at end of file diff --git a/Govor.Core/Models/GroupMembership.cs b/Govor.Core/Models/GroupMembership.cs index d33f40d..d70341e 100644 --- a/Govor.Core/Models/GroupMembership.cs +++ b/Govor.Core/Models/GroupMembership.cs @@ -5,5 +5,6 @@ public class GroupMembership public Guid Id { get; set; } public Guid GroupId { get; set; } public Guid UserId { get; set; } + public Guid InvitationId { get; set; } public bool IsBanned { get; set; } } \ No newline at end of file diff --git a/Govor.Core/Repositories/Groups/IGroupMessagesReader.cs b/Govor.Core/Repositories/Groups/IGroupMessagesReader.cs new file mode 100644 index 0000000..3f5d9ab --- /dev/null +++ b/Govor.Core/Repositories/Groups/IGroupMessagesReader.cs @@ -0,0 +1,8 @@ +using Govor.Core.Models; + +namespace Govor.Core.Repositories.Groups; + +public interface IGroupMessagesReader +{ + public Task> GetMessages(Guid chatId, Guid? startMessageId, int pageSize = 20); +} \ No newline at end of file diff --git a/Govor.Core/Repositories/Groups/IGroupsReader.cs b/Govor.Core/Repositories/Groups/IGroupsReader.cs index 752817d..26829d0 100644 --- a/Govor.Core/Repositories/Groups/IGroupsReader.cs +++ b/Govor.Core/Repositories/Groups/IGroupsReader.cs @@ -1,13 +1,13 @@ -using System.Text.RegularExpressions; +using Govor.Core.Models; namespace Govor.Core.Repositories.Groups; public interface IGroupsReader { - public Task> GetAllAsync(); - public Task GetByIdAsync(Guid id); - public Task> FindByNameAsync(string name); - public Task> GetByAdminIdAsync(Guid adminId); - public Task> GetByUserIdAsync(Guid adminId); - public bool IsUserMemberOfGroupAsync(Guid userId, Guid groupId); + public Task> GetAllAsync(); + public Task GetByIdAsync(Guid id); + public Task> SearchByNameAsync(string name); + public Task> GetByAdminIdAsync(Guid userId); + public Task> GetByUserIdAsync(Guid userId); + public Task IsUserMemberOfGroupAsync(Guid userId, Guid groupId); } \ No newline at end of file diff --git a/Govor.Core/Repositories/Groups/IGroupsWriter.cs b/Govor.Core/Repositories/Groups/IGroupsWriter.cs index dbb2ce4..4cc38f4 100644 --- a/Govor.Core/Repositories/Groups/IGroupsWriter.cs +++ b/Govor.Core/Repositories/Groups/IGroupsWriter.cs @@ -1,10 +1,10 @@ -using System.Text.RegularExpressions; +using Govor.Core.Models; namespace Govor.Core.Repositories.Groups; public interface IGroupsWriter { - Task Add(Group group); - Task Update(Group group); - Task Remove(Guid groupId); + Task AddAsync(ChatGroup group); + Task UpdateAsync(ChatGroup group); + Task RemoveAsync(Guid groupId); } \ No newline at end of file diff --git a/Govor.Core/Repositories/PrivateChats/IPrivateChatsExist.cs b/Govor.Core/Repositories/PrivateChats/IPrivateChatsExist.cs new file mode 100644 index 0000000..af72f01 --- /dev/null +++ b/Govor.Core/Repositories/PrivateChats/IPrivateChatsExist.cs @@ -0,0 +1,7 @@ +namespace Govor.Core.Repositories.PrivateChats; + +public interface IPrivateChatsExist +{ + bool Exist(Guid chatId); + bool Exist(Guid userAId, Guid userBId); +} \ No newline at end of file diff --git a/Govor.Core/Repositories/PrivateChats/IPrivateChatsReader.cs b/Govor.Core/Repositories/PrivateChats/IPrivateChatsReader.cs new file mode 100644 index 0000000..bcc2f83 --- /dev/null +++ b/Govor.Core/Repositories/PrivateChats/IPrivateChatsReader.cs @@ -0,0 +1,10 @@ +using Govor.Core.Models; + +namespace Govor.Core.Repositories.PrivateChats; + +public interface IPrivateChatsReader +{ + Task> GetAllAsync(); + Task GetByIdAsync(Guid id); + Task GetByMembersAsync(Guid memberAId, Guid memberBId); +} \ No newline at end of file diff --git a/Govor.Core/Repositories/PrivateChats/IPrivateChatsRepository.cs b/Govor.Core/Repositories/PrivateChats/IPrivateChatsRepository.cs new file mode 100644 index 0000000..de632d6 --- /dev/null +++ b/Govor.Core/Repositories/PrivateChats/IPrivateChatsRepository.cs @@ -0,0 +1,6 @@ +namespace Govor.Core.Repositories.PrivateChats; + +public interface IPrivateChatsRepository : IPrivateChatsReader, IPrivateChatsWriter, IPrivateChatsExist +{ + +} \ No newline at end of file diff --git a/Govor.Core/Repositories/PrivateChats/IPrivateChatsWriter.cs b/Govor.Core/Repositories/PrivateChats/IPrivateChatsWriter.cs new file mode 100644 index 0000000..d2afa33 --- /dev/null +++ b/Govor.Core/Repositories/PrivateChats/IPrivateChatsWriter.cs @@ -0,0 +1,10 @@ +using Govor.Core.Models; + +namespace Govor.Core.Repositories.PrivateChats; + +public interface IPrivateChatsWriter +{ + Task AddAsync(PrivateChat chat); + Task UpdateAsync(PrivateChat chat); + Task RemoveAsync(Guid chatId); +} \ No newline at end of file diff --git a/Govor.Data/GovorDbContext.cs b/Govor.Data/GovorDbContext.cs index c07d72a..b49c3e8 100644 --- a/Govor.Data/GovorDbContext.cs +++ b/Govor.Data/GovorDbContext.cs @@ -21,6 +21,7 @@ public class GovorDbContext(DbContextOptions options) : DbContex public virtual DbSet MediaFiles { get; set; } public virtual DbSet ChatGroups { get; set; } + public virtual DbSet GroupInvitations { get; set; } public virtual DbSet GroupMemberships { get; set; } public virtual DbSet GroupAdmins { get; set; } diff --git a/Govor.Data/Repositories/GroupRepository.cs b/Govor.Data/Repositories/GroupRepository.cs index cfbc592..daaf32a 100644 --- a/Govor.Data/Repositories/GroupRepository.cs +++ b/Govor.Data/Repositories/GroupRepository.cs @@ -1,62 +1,164 @@ using System.Text.RegularExpressions; +using Govor.Core.Infrastructure.Extensions; +using Govor.Core.Infrastructure.Validators; using Govor.Core.Models; using Govor.Core.Repositories.Groups; +using Govor.Data.Repositories.Exceptions; +using Microsoft.EntityFrameworkCore; namespace Govor.Data.Repositories; public class GroupRepository : IGroupsRepository { - public Task> GetAllAsync() + private GovorDbContext _context; + private IObjectValidator _validator; + + public GroupRepository(GovorDbContext context, IObjectValidator validator) { - throw new NotImplementedException(); - } - - public Task GetByIdAsync(Guid id) - { - throw new NotImplementedException(); - } - - public Task> FindByNameAsync(string name) - { - throw new NotImplementedException(); + _context = context; + _validator = validator; } - public Task> GetByAdminIdAsync(Guid adminId) + public async Task> GetAllAsync() { - throw new NotImplementedException(); + return await _context.ChatGroups + .AsNoTracking() + .Include(g => g.Admins) + .Include(g => g.Members) + .Include(g => g.InviteCodes) + .AsSplitQuery() + .ToListOrThrowIfEmpty(new NotFoundException("Database is empty")); } - public Task> GetByUserIdAsync(Guid adminId) + public async Task GetByIdAsync(Guid id) { - throw new NotImplementedException(); + return await _context.ChatGroups + .AsNoTracking() + .Include(g => g.Admins) + .Include(g => g.Members) + .Include(g => g.InviteCodes) + .AsSplitQuery() + .FirstOrDefaultAsync(g => g.Id == id) + ?? throw new NotFoundByKeyException(id, "Group with given id doesn't exist"); } - public Task Add(Group group) + public async Task> SearchByNameAsync(string name) { - throw new NotImplementedException(); + return await _context.ChatGroups + .AsNoTracking() + .Include(g => g.Admins) + .Include(g => g.Members) + .Include(g => g.InviteCodes) + .AsSplitQuery() + .Where(g => g.Name.ToLower().Contains(name.ToLower())) + .Take(20) + .ToListOrThrowIfEmpty(new NotFoundByKeyException(name, "Group with name doesn't exist")); + } + + public async Task> GetByAdminIdAsync(Guid userId) + { + return await _context.ChatGroups + .AsNoTracking() + .Include(g => g.Admins) + .Include(g => g.Members) + .Include(g => g.InviteCodes) + .AsSplitQuery() + .Where(g => g.Admins.Any(a => a.UserId == userId)) + .ToListOrThrowIfEmpty(new NotFoundByKeyException(userId, "Admin with given id doesn't exist")); } - public Task Update(Group group) + public async Task> GetByUserIdAsync(Guid userId) { - throw new NotImplementedException(); + return await _context.ChatGroups + .AsNoTracking() + .Include(g => g.Admins) + .Include(g => g.Members) + .Include(g => g.InviteCodes) + .AsSplitQuery() + .Where(g => g.Members.Any(a => a.UserId == userId)) + .ToListOrThrowIfEmpty(new NotFoundByKeyException(userId, "Member with given id doesn't exist")); } - public Task Remove(Guid groupId) + public async Task AddAsync(ChatGroup group) { - throw new NotImplementedException(); + try + { + _validator.Validate(group); + + _context.ChatGroups.Add(group); + await _context.SaveChangesAsync(); + } + catch (InvalidObjectException ex) + { + throw new AdditionException("Group with given data invalid", ex); + } + catch (Exception ex) + { + throw new AdditionException("Cannot add group", ex); + } } + + public async Task UpdateAsync(ChatGroup group) + { + try + { + _validator.Validate(group); + + var rowsAffected = await _context.ChatGroups + .Where(u => u.Id == group.Id) + .ExecuteUpdateAsync(g => g + .SetProperty(g => g.Name, group.Name) + .SetProperty(g => g.Description, group.Description) + .SetProperty(g => g.ImageId, group.ImageId) + .SetProperty(g => g.IsChannel, group.IsChannel) + .SetProperty(g => g.IsPrivate, group.IsPrivate) + .SetProperty(g => g.InviteCodes, group.InviteCodes) + ); + + if (rowsAffected == 0) + throw new UpdateException($"Not found group by given id {group.Id}"); + } + catch (Exception ex) + { + throw new UpdateException($"Error when updating the group {group.Id}", ex); + } + } + + public async Task RemoveAsync(Guid groupId) + { + try + { + var result = await GetByIdAsync(groupId); + + _context.ChatGroups.Remove(result); + await _context.SaveChangesAsync(); + } + catch (InvalidObjectException ex) + { + throw new RemoveException("User with given data invalid", ex); + } + } + public bool Exists(Guid groupId) { - throw new NotImplementedException(); + return _context.ChatGroups.Any(g => g.Id == groupId); } public bool Exists(ChatGroup chatGroup) { - throw new NotImplementedException(); + return _context.ChatGroups.Any(g => g.Id == chatGroup.Id && + g.IsChannel == chatGroup.IsChannel && + g.Description == chatGroup.Description && + g.IsPrivate == chatGroup.IsPrivate && + g.Name == chatGroup.Name && + g.ImageId == chatGroup.ImageId); } - public bool IsUserMemberOfGroupAsync(Guid userId, Guid groupId) + public async Task IsUserMemberOfGroupAsync(Guid userId, Guid groupId) { - throw new NotImplementedException(); + return await _context.ChatGroups + .AsNoTracking() + .Include(g => g.Members) + .AnyAsync(g => g.Members.Any(a => a.UserId == userId && a.GroupId == groupId)); } } diff --git a/Govor.Data/Repositories/PrivateChatsRepository.cs b/Govor.Data/Repositories/PrivateChatsRepository.cs new file mode 100644 index 0000000..07836ec --- /dev/null +++ b/Govor.Data/Repositories/PrivateChatsRepository.cs @@ -0,0 +1,115 @@ +using Govor.Core.Infrastructure.Extensions; +using Govor.Core.Infrastructure.Validators; +using Govor.Core.Models; +using Govor.Core.Repositories.PrivateChats; +using Govor.Data.Repositories.Exceptions; +using Microsoft.EntityFrameworkCore; + +namespace Govor.Data.Repositories; + +public class PrivateChatsRepository : IPrivateChatsRepository +{ + private GovorDbContext _context; + private IObjectValidator _validator; + + public PrivateChatsRepository(GovorDbContext context, IObjectValidator validator) + { + _context = context; + _validator = validator; + } + + public async Task> GetAllAsync() + { + return await _context.PrivateChats + .AsNoTracking() + .ToListOrThrowIfEmpty(new NotFoundException("Database is empty")); + } + + public async Task GetByIdAsync(Guid id) + { + return await _context.PrivateChats + .AsNoTracking() + .FirstOrDefaultAsync(p => p.Id == id) + ?? throw new NotFoundByKeyException(id, "Private Chat with given Id does not exist"); + } + + public async Task GetByMembersAsync(Guid memberAId, Guid memberBId) + { + return await _context.PrivateChats + .AsNoTracking() + .FirstOrDefaultAsync(f => (f.UserAId == memberAId && f.UserBId == memberBId) || (f.UserAId == memberBId && f.UserBId == memberAId)) + ?? throw new NotFoundByKeyException<(Guid, Guid)>((memberAId, memberBId), "Private Chat with given members Id's does not exist"); + } + + public async Task AddAsync(PrivateChat chat) + { + try + { + _validator.Validate(chat); + + _context.PrivateChats.Add(chat); + await _context.SaveChangesAsync(); + } + catch (InvalidObjectException ex) + { + throw new AdditionException("Private chat with given data invalid", ex); + } + catch (Exception ex) + { + throw new AdditionException("Cannot add private chat", ex); + } + } + + public async Task UpdateAsync(PrivateChat chat) + { + try + { + _validator.Validate(chat); + + var rowsAffected = await _context.PrivateChats + .Where(m => m.Id == chat.Id) + .ExecuteUpdateAsync(c => c + .SetProperty(c => c.UserAId, chat.UserAId) + .SetProperty(c => c.UserBId, chat.UserBId) + .SetProperty(c => c.Messages, chat.Messages) + ); + + if (rowsAffected == 0) + throw new UpdateException($"Not found private chat by given id {chat.Id}"); + } + catch (Exception ex) + { + throw new UpdateException($"Error when updating the private chat {chat.Id}", ex); + } + } + + public async Task RemoveAsync(Guid chatId) + { + try + { + var result = await GetByIdAsync(chatId); + + _context.PrivateChats.Remove(result); + await _context.SaveChangesAsync(); + } + catch (NotFoundByKeyException ex) + { + throw new RemoveException($"Not found private chat by given id {chatId}", ex); + } + catch (Exception ex) + { + throw new RemoveException("Error when removing the private chat", ex); + } + } + + public bool Exist(Guid chatId) + { + return _context.PrivateChats.Any(c => c.Id == chatId); + } + + public bool Exist(Guid userAId, Guid userBId) + { + return _context.PrivateChats.Any(f => (f.UserAId == userAId && f.UserBId == userBId) + || (f.UserAId == userBId && f.UserBId == userAId)); + } +} \ No newline at end of file From b027b20fc3773f76837d1bb163162d9a27a7876a Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Mon, 7 Jul 2025 14:05:11 +0700 Subject: [PATCH 031/101] ChatGroupValidator --- .../Extensions/ConfigurationProgramExtensions.cs | 1 + .../Validators/ChatGroupValidator.cs | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 Govor.Core/Infrastructure/Validators/ChatGroupValidator.cs diff --git a/Govor.API/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Extensions/ConfigurationProgramExtensions.cs index 7e45361..c727014 100644 --- a/Govor.API/Extensions/ConfigurationProgramExtensions.cs +++ b/Govor.API/Extensions/ConfigurationProgramExtensions.cs @@ -75,6 +75,7 @@ public static class ConfigurationProgramExtensions services.AddScoped, InvitationValidator>(); services.AddScoped, FriendshipValidator>(); services.AddScoped, PrivateChatValidator>(); + services.AddScoped, ChatGroupValidator>(); } public static void AddGovorDbContext(this IServiceCollection services, IConfiguration configuration) diff --git a/Govor.Core/Infrastructure/Validators/ChatGroupValidator.cs b/Govor.Core/Infrastructure/Validators/ChatGroupValidator.cs new file mode 100644 index 0000000..d6c8b58 --- /dev/null +++ b/Govor.Core/Infrastructure/Validators/ChatGroupValidator.cs @@ -0,0 +1,16 @@ +using Govor.Core.Models; + +namespace Govor.Core.Infrastructure.Validators; + +public class ChatGroupValidator : IObjectValidator +{ + public void Validate(ChatGroup objectToValidate) + { + throw new NotImplementedException(); + } + + public bool TryValidate(ChatGroup objectToValidate) + { + throw new NotImplementedException(); + } +} \ No newline at end of file From baeafad007e61969cf9d69ac75a33c6907965d6a Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Mon, 7 Jul 2025 19:33:47 +0700 Subject: [PATCH 032/101] PrivateChatsRepositoryTests --- .../PrivateChatsRepositoryTests.cs | 196 ++++++++++++++++++ .../Validators/ChatGroupValidatorTests.cs | 7 + .../Validators/PrivateChatValidatorTests.cs | 70 +++++++ .../UnitTests/Services/MessageServiceTests.cs | 20 +- .../Validators/ChatGroupValidator.cs | 36 +++- Govor.Core/Models/PrivateChat.cs | 8 + 6 files changed, 323 insertions(+), 14 deletions(-) create mode 100644 Govor.API.Tests/UnitTests/Infrastructure/Validators/ChatGroupValidatorTests.cs diff --git a/Govor.API.Tests/IntegrationTests/EF/Repositories/PrivateChatsRepositoryTests.cs b/Govor.API.Tests/IntegrationTests/EF/Repositories/PrivateChatsRepositoryTests.cs index 41467ef..90729ca 100644 --- a/Govor.API.Tests/IntegrationTests/EF/Repositories/PrivateChatsRepositoryTests.cs +++ b/Govor.API.Tests/IntegrationTests/EF/Repositories/PrivateChatsRepositoryTests.cs @@ -1,7 +1,203 @@ +using AutoFixture; +using Govor.Core.Infrastructure.Validators; +using Govor.Core.Models; +using Govor.Data; +using Govor.Data.Repositories; +using Govor.Data.Repositories.Exceptions; +using Microsoft.EntityFrameworkCore; + namespace Govor.API.Tests.IntegrationTests.EF.Repositories; [TestFixture] public class PrivateChatsRepositoryTests { + private Fixture _fixture; + private DbContextOptions _options; + private IObjectValidator _validator = new PrivateChatValidator(); + private int _testIteration = 0; + [SetUp] + public void SetUp() + { + _testIteration += 1; + + _fixture = new Fixture(); + + _fixture.Behaviors + .OfType() + .ToList() + .ForEach(b => _fixture.Behaviors.Remove(b)); + + _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); + + _options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: $"DbGovor_{nameof(PrivateChatsRepositoryTests)}_{_testIteration}") + .Options; + } + + [Test] + public async Task Given_NotEmptySetDb_When_GetAll_Then_ReturnAll() + { + // Arrange + var random = new Random(); + var chats = _fixture.CreateMany(random.Next(2, 10)).ToList(); + + await using var context = new GovorDbContext(_options); + var messagesRepository = new PrivateChatsRepository(context, _validator); + + context.PrivateChats.AddRange(chats); + await context.SaveChangesAsync(); + + // Act + + var result = await messagesRepository.GetAllAsync(); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.Count, Is.EqualTo(chats.Count)); + Assert.That(result, Is.EquivalentTo(chats)); + } + + [Test] + public void Given_EmptySetDb_When_GetAll_Should_Throw_NotFoundException() + { + // Arrange + using var context = new GovorDbContext(_options); + var repository = new PrivateChatsRepository(context, _validator); + // Act & Assert + Assert.ThrowsAsync(async () => await repository.GetAllAsync()); + } + + [Test] + public async Task Given_ValidChatId_When_GetById_Then_ReturnChat() + { + // Arrange + var chats = _fixture.CreateMany(10); + var id = chats.First().Id; + + await using var context = new GovorDbContext(_options); + var repository = new PrivateChatsRepository(context, _validator); + + context.PrivateChats.AddRange(chats); + await context.SaveChangesAsync(); + + // Act + var result = await repository.GetByIdAsync(id); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result, Is.EqualTo(chats.First())); + } + + [Test] + public async Task Given_InvalidMessageId_When_FindById_Should_Throw_NotFoundByKeyException() + { + // Arrange + await using var context = new GovorDbContext(_options); + var repository = new PrivateChatsRepository(context, _validator); + + // Act & Assert + Assert.ThrowsAsync>(async () => await repository.GetByIdAsync(_fixture.Create())); + } + + [Test] + public async Task Given_MembersId_When_GetByMembersAsync_Then_ReturnChat() + { + // Arrange + var chats = _fixture.CreateMany(10); + var id1 = chats.First().UserAId; + var id2 = chats.First().UserBId; + + await using var context = new GovorDbContext(_options); + var repository = new PrivateChatsRepository(context, _validator); + + context.PrivateChats.AddRange(chats); + await context.SaveChangesAsync(); + + // Act + var result = await repository.GetByMembersAsync(id1, id2); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result, Is.EqualTo(chats.First())); + } + + [Test] + public async Task Given_InvalidMembersId_When_GetByMembersAsync_Should_Throw_NotFoundByKeyException() + { + // Arrange + await using var context = new GovorDbContext(_options); + var repository = new PrivateChatsRepository(context, _validator); + + // Act & Assert + Assert.ThrowsAsync>(async () => await repository.GetByMembersAsync(_fixture.Create(), _fixture.Create())); + } + + [Test] + public async Task Given_ValidMessage_When_AddAsync_Then_MessageAdded() + { + // Arrange + var chat = _fixture.Create(); + + await using var context = new GovorDbContext(_options); + var repository = new PrivateChatsRepository(context, _validator); + + // Act + await repository.AddAsync(chat); + + // Assert + Assert.That(context.PrivateChats.Count, Is.EqualTo(1)); + Assert.That(context.PrivateChats.First(), Is.EqualTo(chat)); + } + + [Test] + public async Task Given_InvalidMessage_When_AddAsync_Should_Throw_AdditionException() + { + // Arrange + await using var context = new GovorDbContext(_options); + var repository = new PrivateChatsRepository(context, _validator); + + // Act & Assert + Assert.ThrowsAsync(async () => await repository.AddAsync(default)); + } + + [Test] + public async Task Given_ExistMessage_When_Exist_Then_ReturnTrue() + { + // Arrange + var chat = _fixture.Create(); + + await using var context = new GovorDbContext(_options); + var repository = new PrivateChatsRepository(context, _validator); + + context.PrivateChats.Add(chat); + await context.SaveChangesAsync(); + + // Act + var result1 = repository.Exist(chat.UserAId, chat.UserBId); + var result2 = repository.Exist(chat.Id); + + + // Assert + Assert.That(result1, Is.True); + Assert.That(result2, Is.True); + } + + [Test] + public async Task Given_NotExistMessage_When_Exist_Then_ReturnFalse() + { + // Arrange + var chat = _fixture.Create(); + + await using var context = new GovorDbContext(_options); + var repository = new PrivateChatsRepository(context, _validator); + + // Act + var result1 = repository.Exist(chat.UserAId, chat.UserBId); + var result2 = repository.Exist(chat.Id); + + // Assert + Assert.That(result1, Is.False); + Assert.That(result2, Is.False); + } } \ No newline at end of file diff --git a/Govor.API.Tests/UnitTests/Infrastructure/Validators/ChatGroupValidatorTests.cs b/Govor.API.Tests/UnitTests/Infrastructure/Validators/ChatGroupValidatorTests.cs new file mode 100644 index 0000000..6dadf64 --- /dev/null +++ b/Govor.API.Tests/UnitTests/Infrastructure/Validators/ChatGroupValidatorTests.cs @@ -0,0 +1,7 @@ +namespace Govor.API.Tests.UnitTests.Infrastructure.Validators; + +[TestFixture] +public class ChatGroupValidatorTests +{ + +} \ No newline at end of file diff --git a/Govor.API.Tests/UnitTests/Infrastructure/Validators/PrivateChatValidatorTests.cs b/Govor.API.Tests/UnitTests/Infrastructure/Validators/PrivateChatValidatorTests.cs index 8abd1fd..43d54d1 100644 --- a/Govor.API.Tests/UnitTests/Infrastructure/Validators/PrivateChatValidatorTests.cs +++ b/Govor.API.Tests/UnitTests/Infrastructure/Validators/PrivateChatValidatorTests.cs @@ -1,7 +1,77 @@ +using AutoFixture; +using Govor.Core.Infrastructure.Validators; +using Govor.Core.Models; + namespace Govor.API.Tests.UnitTests.Infrastructure.Validators; [TestFixture] public class PrivateChatValidatorTests { + private IObjectValidator _validator = new PrivateChatValidator(); + private Fixture _fixture; + [SetUp] + public void SetUp() + { + _fixture = new Fixture(); + + _fixture.Behaviors + .OfType() + .ToList() + .ForEach(b => _fixture.Behaviors.Remove(b)); + + _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); + } + + [Test] + public void Validate_PrivateChat_Valid() + { + // Arrange + var privateChat = _fixture.Create(); + + // Act & Assert + Assert.DoesNotThrow(() => _validator.Validate(privateChat)); + Assert.That(_validator.TryValidate(privateChat), Is.True); + } + + [Test] + public void Validate_When_PrivateChatIsNull_Invalid() + { + // Act & Assert + Assert.Throws>(() => _validator.Validate(default)); + Assert.That(_validator.TryValidate(default), Is.False); + } + + [Test] + public void Validate_When_PrivateChatIdIsEmpty_Invalid() + { + // Arrange + var privateChat = _fixture.Create(); + privateChat.Id = Guid.Empty; + // Act & Assert + Assert.Throws>(() => _validator.Validate(privateChat)); + Assert.That(_validator.TryValidate(privateChat), Is.False); + } + + [Test] + public void Validate_When_UserAIdIsEmpty_Invalid() + { + // Arrange + var privateChat = _fixture.Create(); + privateChat.UserAId = Guid.Empty; + // Act & Assert + Assert.Throws>(() => _validator.Validate(privateChat)); + Assert.That(_validator.TryValidate(privateChat), Is.False); + } + + [Test] + public void Validate_When_UserBIdIsEmpty_Invalid() + { + // Arrange + var privateChat = _fixture.Create(); + privateChat.UserBId = Guid.Empty; + // Act & Assert + Assert.Throws>(() => _validator.Validate(privateChat)); + Assert.That(_validator.TryValidate(privateChat), Is.False); + } } \ No newline at end of file diff --git a/Govor.API.Tests/UnitTests/Services/MessageServiceTests.cs b/Govor.API.Tests/UnitTests/Services/MessageServiceTests.cs index 3260d5b..8aefb5b 100644 --- a/Govor.API.Tests/UnitTests/Services/MessageServiceTests.cs +++ b/Govor.API.Tests/UnitTests/Services/MessageServiceTests.cs @@ -69,7 +69,7 @@ public class MessageServiceTests var result = await _messageService.SendMessageAsync(sendMessageParams); // Assert Assert.That(result, Is.Not.Null); - Assert.That(result.IsSuccess, Is.EqualTo(true)); + Assert.That(result.IsSuccess, Is.True); Assert.That(result.Exception, Is.Null); _mockMessagesRepo.Verify(r => r.AddAsync(It.Is(m => @@ -103,7 +103,7 @@ public class MessageServiceTests // Assert Assert.That(result, Is.Not.Null); - Assert.That(result.IsSuccess, Is.EqualTo(true)); + Assert.That(result.IsSuccess, Is.True); Assert.That(result.Exception, Is.Null); _mockMessagesRepo.Verify(r => r.AddAsync(It.Is(m => m.RecipientId == groupId && @@ -132,7 +132,7 @@ public class MessageServiceTests // Assert Assert.That(result, Is.Not.Null); - Assert.That(result.IsSuccess, Is.EqualTo(false)); + Assert.That(result.IsSuccess, Is.False); Assert.That(result.Exception, Is.Not.Null); Assert.That(result.Exception,Is.TypeOf()); Assert.That(result.Message, Is.Null); @@ -162,7 +162,7 @@ public class MessageServiceTests // Assert Assert.That(result, Is.Not.Null); - Assert.That(result.IsSuccess, Is.EqualTo(false)); + Assert.That(result.IsSuccess, Is.False); Assert.That(result.Exception, Is.Not.Null); Assert.That(result.Exception,Is.TypeOf()); Assert.That(result.Message, Is.Null); @@ -197,7 +197,7 @@ public class MessageServiceTests // Assert Assert.That(result, Is.Not.Null); - Assert.That(result.IsSuccess, Is.EqualTo(true)); + Assert.That(result.IsSuccess, Is.True); Assert.That(result.OriginalMessage, Is.Not.Null); Assert.That(messageId, Is.EqualTo(result.OriginalMessage!.Id)); @@ -225,7 +225,7 @@ public class MessageServiceTests // Assert Assert.That(result, Is.Not.Null); - Assert.That(result.IsSuccess, Is.EqualTo(false)); + Assert.That(result.IsSuccess, Is.False); Assert.That(result.Exception, Is.Not.Null); Assert.That(result.Exception,Is.TypeOf>()); Assert.That(result.OriginalMessage, Is.Null); @@ -248,7 +248,7 @@ public class MessageServiceTests // Assert Assert.That(result, Is.Not.Null); - Assert.That(result.IsSuccess, Is.EqualTo(false)); + Assert.That(result.IsSuccess, Is.False); Assert.That(result.Exception, Is.Not.Null); Assert.That(result.Exception,Is.TypeOf()); Assert.That(result.OriginalMessage, Is.Null); @@ -272,7 +272,7 @@ public class MessageServiceTests // Assert Assert.That(result, Is.Not.Null); - Assert.That(result.IsSuccess, Is.EqualTo(true)); + Assert.That(result.IsSuccess, Is.True); Assert.That(result.OriginalMessage, Is.Not.Null); Assert.That(messageId, Is.EqualTo(result.OriginalMessage!.Id)); _mockMessagesRepo.Verify(r => r.RemoveAsync(messageId), Times.Once); @@ -294,7 +294,7 @@ public class MessageServiceTests // Assert Assert.That(result, Is.Not.Null); - Assert.That(result.IsSuccess, Is.EqualTo(false)); + Assert.That(result.IsSuccess, Is.False); Assert.That(result.Exception, Is.Not.Null); Assert.That(result.Exception,Is.TypeOf>()); Assert.That(result.OriginalMessage, Is.Null); @@ -317,7 +317,7 @@ public class MessageServiceTests // Assert Assert.That(result, Is.Not.Null); - Assert.That(result.IsSuccess, Is.EqualTo(false)); + Assert.That(result.IsSuccess, Is.False); Assert.That(result.Exception, Is.Not.Null); Assert.That(result.Exception,Is.TypeOf()); Assert.That(result.OriginalMessage, Is.Null); diff --git a/Govor.Core/Infrastructure/Validators/ChatGroupValidator.cs b/Govor.Core/Infrastructure/Validators/ChatGroupValidator.cs index d6c8b58..c8e70ff 100644 --- a/Govor.Core/Infrastructure/Validators/ChatGroupValidator.cs +++ b/Govor.Core/Infrastructure/Validators/ChatGroupValidator.cs @@ -4,13 +4,41 @@ namespace Govor.Core.Infrastructure.Validators; public class ChatGroupValidator : IObjectValidator { - public void Validate(ChatGroup objectToValidate) + public void Validate(ChatGroup chat) { - throw new NotImplementedException(); + try + { + if(chat is null) + throw new ArgumentNullException(nameof(chat)); + if(chat.Id == Guid.Empty) + throw new ArgumentException("Id of chat group can't be empty",nameof(chat.Id)); + if(string.IsNullOrEmpty(chat.Name)) + throw new ArgumentException("Name of chat group can't be empty",nameof(chat.Name)); + if(chat.Description is null) + throw new ArgumentException("Description of chat group can't be null",nameof(chat.Description)); + if(chat.ImageId == Guid.Empty) + throw new ArgumentException("ImageId of chat group can't be empty",nameof(chat.ImageId)); + if(chat.IsPrivate && chat.InviteCodes.Count <= 0) + throw new ArgumentException("Private group must have invitation links",nameof(chat.InviteCodes)); + if(chat.Admins.Count <= 0) + throw new ArgumentException("Chat must have owner",nameof(chat.Admins)); + } + catch (Exception ex) + { + throw new InvalidObjectException(ex); + } } - public bool TryValidate(ChatGroup objectToValidate) + public bool TryValidate(ChatGroup chat) { - throw new NotImplementedException(); + try + { + Validate(chat); + return true; + } + catch (Exception) + { + return false; + } } } \ No newline at end of file diff --git a/Govor.Core/Models/PrivateChat.cs b/Govor.Core/Models/PrivateChat.cs index 82ef1e5..51f0921 100644 --- a/Govor.Core/Models/PrivateChat.cs +++ b/Govor.Core/Models/PrivateChat.cs @@ -6,4 +6,12 @@ public class PrivateChat public Guid UserAId { get; set; } public Guid UserBId { get; set; } public List Messages { get; set; } = new List(); + + public override bool Equals(object? obj) + { + PrivateChat other = obj as PrivateChat; + return Id == other.Id && + UserAId == other.UserAId && + UserBId == other.UserBId; + } } \ No newline at end of file From 42be589a26c7b53525b613f6dd4afe82e725ab18 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Mon, 7 Jul 2025 19:46:45 +0700 Subject: [PATCH 033/101] Update ChatGroupValidatorTests.cs --- .../Validators/ChatGroupValidatorTests.cs | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/Govor.API.Tests/UnitTests/Infrastructure/Validators/ChatGroupValidatorTests.cs b/Govor.API.Tests/UnitTests/Infrastructure/Validators/ChatGroupValidatorTests.cs index 6dadf64..42986c0 100644 --- a/Govor.API.Tests/UnitTests/Infrastructure/Validators/ChatGroupValidatorTests.cs +++ b/Govor.API.Tests/UnitTests/Infrastructure/Validators/ChatGroupValidatorTests.cs @@ -1,7 +1,118 @@ +using AutoFixture; +using Govor.Core.Infrastructure.Validators; +using Govor.Core.Models; + namespace Govor.API.Tests.UnitTests.Infrastructure.Validators; [TestFixture] public class ChatGroupValidatorTests { + private IObjectValidator _validator = new ChatGroupValidator(); + private Fixture _fixture; + [SetUp] + public void SetUp() + { + _fixture = new Fixture(); + + _fixture.Behaviors + .OfType() + .ToList() + .ForEach(b => _fixture.Behaviors.Remove(b)); + + _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); + } + + [Test] + public void Validate_ChatGroup_Valid() + { + // Arrange + var chat = _fixture.Create(); + + // Act & Assert + Assert.DoesNotThrow(() => _validator.Validate(chat)); + Assert.That(_validator.TryValidate(chat), Is.True); + } + + + [Test] + public void Validate_ChatGroupIsNull_Invalid() + { + // Act & Assert + Assert.Throws>(() => _validator.Validate(default)); + Assert.That(_validator.TryValidate(default), Is.False); + } + + [Test] + public void Validate_ChatGroupIdIsEmpty_Invalid() + { + // Arrange + var chat = _fixture.Create(); + chat.Id = Guid.Empty; + + // Act & Assert + Assert.Throws>(() => _validator.Validate(chat)); + Assert.That(_validator.TryValidate(chat), Is.False); + } + + [Test] + public void Validate_ChatGroupNameIsEmpty_Invalid() + { + // Arrange + var chat = _fixture.Create(); + chat.Name = ""; + + // Act & Assert + Assert.Throws>(() => _validator.Validate(chat)); + Assert.That(_validator.TryValidate(chat), Is.False); + } + + [Test] + public void Validate_ChatGroupDescriptionIsNull_Invalid() + { + // Arrange + var chat = _fixture.Create(); + chat.Description = default; + + // Act & Assert + Assert.Throws>(() => _validator.Validate(chat)); + Assert.That(_validator.TryValidate(chat), Is.False); + } + + [Test] + public void Validate_ChatGroupImageIdIsEmpty_Invalid() + { + // Arrange + var chat = _fixture.Create(); + chat.ImageId = Guid.Empty; + + // Act & Assert + Assert.Throws>(() => _validator.Validate(chat)); + Assert.That(_validator.TryValidate(chat), Is.False); + } + + [Test] + public void Validate_ChatGroupIsPrivateAndInvitesCodesIsEmpty_Invalid() + { + // Arrange + var chat = _fixture.Create(); + chat.IsPrivate = true; + chat.InviteCodes = new(); + + // Act & Assert + Assert.Throws>(() => _validator.Validate(chat)); + Assert.That(_validator.TryValidate(chat), Is.False); + } + + [Test] + public void Validate_ChatGroupAdminsIsEmpty_Invalid() + { + // Arrange + var chat = _fixture.Create(); + chat.Admins = new(); + + // Act & Assert + Assert.Throws>(() => _validator.Validate(chat)); + Assert.That(_validator.TryValidate(chat), Is.False); + } } \ No newline at end of file From a68feb4a1739c2009abbb4f125794070bab8a2e2 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Mon, 7 Jul 2025 20:49:20 +0700 Subject: [PATCH 034/101] Refactor group existence checks and add group repo tests Renamed IGroupsExist and related methods from Exists to Exist for consistency. Added integration tests for GroupRepository. Introduced entity configurations for ChatGroup, GroupAdmins, GroupInvitation, and updated GroupMembership configuration. Updated usages and tests to match new method names and improved equality checks for ChatGroup. --- .../EF/Repositories/GroupRepositoryTests.cs | 308 ++++++++++++++++++ .../PrivateChatsRepositoryTests.cs | 13 +- .../UnitTests/Services/MessageServiceTests.cs | 2 +- Govor.Application/Services/MessageService.cs | 2 +- Govor.Core/Models/ChatGroup.cs | 12 + .../Repositories/Groups/IGroupsExist.cs | 4 +- .../Configurations/ChatGroupConfigurator.cs | 31 ++ .../GroupAdminsConfiguration.cs | 16 + .../GroupInvitationConfiguration.cs | 26 ++ .../GroupMembershipConfiguration.cs | 12 +- Govor.Data/GovorDbContext.cs | 4 + Govor.Data/Repositories/GroupRepository.cs | 4 +- 12 files changed, 421 insertions(+), 13 deletions(-) create mode 100644 Govor.API.Tests/IntegrationTests/EF/Repositories/GroupRepositoryTests.cs create mode 100644 Govor.Data/Configurations/ChatGroupConfigurator.cs create mode 100644 Govor.Data/Configurations/GroupAdminsConfiguration.cs create mode 100644 Govor.Data/Configurations/GroupInvitationConfiguration.cs diff --git a/Govor.API.Tests/IntegrationTests/EF/Repositories/GroupRepositoryTests.cs b/Govor.API.Tests/IntegrationTests/EF/Repositories/GroupRepositoryTests.cs new file mode 100644 index 0000000..3fac8c6 --- /dev/null +++ b/Govor.API.Tests/IntegrationTests/EF/Repositories/GroupRepositoryTests.cs @@ -0,0 +1,308 @@ +using AutoFixture; +using Govor.Core.Infrastructure.Validators; +using Govor.Core.Models; +using Govor.Data; +using Govor.Data.Repositories; +using Govor.Data.Repositories.Exceptions; +using Microsoft.EntityFrameworkCore; + +namespace Govor.API.Tests.IntegrationTests.EF.Repositories; + +[TestFixture] +public class GroupRepositoryTests +{ + private Fixture _fixture; + private DbContextOptions _options; + private IObjectValidator _validator = new ChatGroupValidator(); + private int _testIteration = 0; + + [SetUp] + public void SetUp() + { + _testIteration += 1; + + _fixture = new Fixture(); + + _fixture.Behaviors + .OfType() + .ToList() + .ForEach(b => _fixture.Behaviors.Remove(b)); + + _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); + + _options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: $"DbGovor_{nameof(GroupRepositoryTests)}_{_testIteration}") + .Options; + } + + [Test] + public async Task Given_NotEmptySetDb_When_GetAll_Then_ReturnAll() + { + // Arrange + var random = new Random(); + var chats = _fixture.CreateMany(random.Next(2, 10)).ToList(); + + await using var context = new GovorDbContext(_options); + var repository = new GroupRepository(context, _validator); + + context.ChatGroups.AddRange(chats); + await context.SaveChangesAsync(); + + // Act + + var result = await repository.GetAllAsync(); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.Count, Is.EqualTo(chats.Count)); + Assert.That(result, Is.EquivalentTo(chats)); + } + + [Test] + public void Given_EmptySetDb_When_GetAll_Should_Throw_NotFoundException() + { + // Arrange + using var context = new GovorDbContext(_options); + var repository = new GroupRepository(context, _validator); + + // Act & Assert + Assert.ThrowsAsync(async () => await repository.GetAllAsync()); + } + + [Test] + public async Task Given_ValidChatId_When_GetById_Then_ReturnChat() + { + // Arrange + var chats = _fixture.CreateMany(10); + var id = chats.First().Id; + + await using var context = new GovorDbContext(_options); + var repository = new GroupRepository(context, _validator); + + context.ChatGroups.AddRange(chats); + await context.SaveChangesAsync(); + + // Act + var result = await repository.GetByIdAsync(id); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result, Is.EqualTo(chats.First())); + } + + [Test] + public void Given_InvalidMessageId_When_FindById_Should_Throw_NotFoundByKeyException() + { + // Arrange + using var context = new GovorDbContext(_options); + var repository = new GroupRepository(context, _validator); + + // Act & Assert + Assert.ThrowsAsync>(async () => + await repository.GetByIdAsync(_fixture.Create())); + } + + [Test] + public async Task Given_ValidQuery_When_SearchByNameAsync_Then_Returns_ChatGroups() + { + // Arrange + var random = new Random(); + var chats = _fixture.CreateMany(random.Next(3, 10)).ToList(); + + using var context = new GovorDbContext(_options); + var repository = new GroupRepository(context, _validator); + + context.ChatGroups.AddRange(chats); + await context.SaveChangesAsync(); + + // Act + var result = await repository.SearchByNameAsync(chats[0].Name[..14]); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.Count, Is.EqualTo(1)); + Assert.That(result.First(), Is.EqualTo(chats.First())); + } + + [Test] + public void Given_InvalidQuery_When_SearchPotentialFriendsAsync_Should_Throw_NotFoundByKeyException() + { + // Arrange + using var context = new GovorDbContext(_options); + var repository = new GroupRepository(context, _validator); + + // Act & Assert + Assert.ThrowsAsync>(async () => await + repository.SearchByNameAsync(_fixture.Create())); + } + + [Test] + public async Task Given_ValidAdminId_When_GetByAdminIdAsync_Returns_ChatGroups() + { + // Arrange + var random = new Random(); + var chats = _fixture.CreateMany(random.Next(3, 10)).ToList(); + var adminId = chats.First().Admins.First().UserId; + + using var context = new GovorDbContext(_options); + var repository = new GroupRepository(context, _validator); + + context.ChatGroups.AddRange(chats); + await context.SaveChangesAsync(); + // Act + var result = await repository.GetByAdminIdAsync(adminId); + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.Count, Is.EqualTo(1)); + Assert.That(result.First(), Is.EqualTo(chats.First())); + } + + [Test] + public void Given_ValidInvalidAdminId_When_GetByAdminIdAsync_Throws_NotFoundByKeyException() + { + // Arrange + using var context = new GovorDbContext(_options); + var repository = new GroupRepository(context, _validator); + + // Act & Assert + Assert.ThrowsAsync>(async () => await + repository.GetByAdminIdAsync(_fixture.Create())); + } + [Test] + public async Task Given_ValidMemberId_When_GetByAdminIdAsync_Returns_ChatGroups() + { + // Arrange + var random = new Random(); + var chats = _fixture.CreateMany(random.Next(3, 10)).ToList(); + var userId = chats.First().Members.First().UserId; + + using var context = new GovorDbContext(_options); + var repository = new GroupRepository(context, _validator); + + context.ChatGroups.AddRange(chats); + await context.SaveChangesAsync(); + // Act + var result = await repository.GetByUserIdAsync(userId); + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.Count, Is.EqualTo(1)); + Assert.That(result.First(), Is.EqualTo(chats.First())); + } + + [Test] + public void Given_ValidInvalidMemberId_When_GetByAdminIdAsync_Throws_NotFoundByKeyException() + { + // Arrange + using var context = new GovorDbContext(_options); + var repository = new GroupRepository(context, _validator); + + // Act & Assert + Assert.ThrowsAsync>(async () => await + repository.GetByUserIdAsync(_fixture.Create())); + } + + [Test] + public async Task Given_ValidChatGroup_When_AddAsync_Then_PrivateChatAdded() + { + // Arrange + var chat = _fixture.Create(); + + await using var context = new GovorDbContext(_options); + var repository = new GroupRepository(context, _validator); + + // Act + await repository.AddAsync(chat); + + // Assert + Assert.That(context.ChatGroups.Count, Is.EqualTo(1)); + Assert.That(context.ChatGroups.First(), Is.EqualTo(chat)); + } + + [Test] + public void Given_InvalidChatGroup_When_AddAsync_Should_Throw_AdditionException() + { + // Arrange + using var context = new GovorDbContext(_options); + var repository = new GroupRepository(context, _validator); + + // Act & Assert + Assert.ThrowsAsync(async () => await repository.AddAsync(default)); + } + + [Test] + public async Task Given_ExistChatGroup_When_Exist_Then_ReturnTrue() + { + // Arrange + var chat = _fixture.Create(); + + await using var context = new GovorDbContext(_options); + var repository = new GroupRepository(context, _validator); + + context.ChatGroups.Add(chat); + await context.SaveChangesAsync(); + + // Act + var result1 = repository.Exist(chat.Id); + var result2 = repository.Exist(chat); + + + // Assert + Assert.That(result1, Is.True); + Assert.That(result2, Is.True); + } + + [Test] + public async Task Given_NotExistChatGroup_When_Exist_Then_ReturnFalse() + { + // Arrange + var chat = _fixture.Create(); + + await using var context = new GovorDbContext(_options); + var repository = new GroupRepository(context, _validator); + + // Act + var result1 = repository.Exist(chat.Id); + var result2 = repository.Exist(chat); + + // Assert + Assert.That(result1, Is.False); + Assert.That(result2, Is.False); + } + + [Test] + public async Task Given_ValidUserIdAndChatGroupId_When_IsUserMemberOfGroupAsync_Then_ReturnTrue() + { + // Arrange + var chat = _fixture.Create(); + var userId = chat.Members.First().UserId; + + await using var context = new GovorDbContext(_options); + var repository = new GroupRepository(context, _validator); + + await context.ChatGroups.AddAsync(chat); + await context.SaveChangesAsync(); + + // Act + var result = await repository.IsUserMemberOfGroupAsync(userId, chat.Id); + + // Assert + Assert.That(result, Is.True); + } + + [Test] + public async Task Given_InvalidValidUserIdAndChatGroupId_When_IsUserMemberOfGroupAsync_Then_ReturnFalse() + { + // Arrange + var chat = _fixture.Create(); + var userId = chat.Members.First().UserId; + + await using var context = new GovorDbContext(_options); + var repository = new GroupRepository(context, _validator); + + // Act + var result = await repository.IsUserMemberOfGroupAsync(userId, chat.Id); + + // Assert + Assert.That(result, Is.False); + } +} \ No newline at end of file diff --git a/Govor.API.Tests/IntegrationTests/EF/Repositories/PrivateChatsRepositoryTests.cs b/Govor.API.Tests/IntegrationTests/EF/Repositories/PrivateChatsRepositoryTests.cs index 90729ca..387beff 100644 --- a/Govor.API.Tests/IntegrationTests/EF/Repositories/PrivateChatsRepositoryTests.cs +++ b/Govor.API.Tests/IntegrationTests/EF/Repositories/PrivateChatsRepositoryTests.cs @@ -43,14 +43,14 @@ public class PrivateChatsRepositoryTests var chats = _fixture.CreateMany(random.Next(2, 10)).ToList(); await using var context = new GovorDbContext(_options); - var messagesRepository = new PrivateChatsRepository(context, _validator); + var repository = new PrivateChatsRepository(context, _validator); context.PrivateChats.AddRange(chats); await context.SaveChangesAsync(); // Act - var result = await messagesRepository.GetAllAsync(); + var result = await repository.GetAllAsync(); // Assert Assert.That(result, Is.Not.Null); @@ -134,7 +134,7 @@ public class PrivateChatsRepositoryTests } [Test] - public async Task Given_ValidMessage_When_AddAsync_Then_MessageAdded() + public async Task Given_ValidPrivateChat_When_AddAsync_Then_PrivateChatAdded() { // Arrange var chat = _fixture.Create(); @@ -151,7 +151,7 @@ public class PrivateChatsRepositoryTests } [Test] - public async Task Given_InvalidMessage_When_AddAsync_Should_Throw_AdditionException() + public async Task Given_InvalidPrivateChat_When_AddAsync_Should_Throw_AdditionException() { // Arrange await using var context = new GovorDbContext(_options); @@ -162,7 +162,7 @@ public class PrivateChatsRepositoryTests } [Test] - public async Task Given_ExistMessage_When_Exist_Then_ReturnTrue() + public async Task Given_ExistPrivateChat_When_Exist_Then_ReturnTrue() { // Arrange var chat = _fixture.Create(); @@ -184,7 +184,7 @@ public class PrivateChatsRepositoryTests } [Test] - public async Task Given_NotExistMessage_When_Exist_Then_ReturnFalse() + public async Task Given_NotExistPrivateChat_When_Exist_Then_ReturnFalse() { // Arrange var chat = _fixture.Create(); @@ -200,4 +200,5 @@ public class PrivateChatsRepositoryTests Assert.That(result1, Is.False); Assert.That(result2, Is.False); } + } \ No newline at end of file diff --git a/Govor.API.Tests/UnitTests/Services/MessageServiceTests.cs b/Govor.API.Tests/UnitTests/Services/MessageServiceTests.cs index 8aefb5b..d415335 100644 --- a/Govor.API.Tests/UnitTests/Services/MessageServiceTests.cs +++ b/Govor.API.Tests/UnitTests/Services/MessageServiceTests.cs @@ -94,7 +94,7 @@ public class MessageServiceTests DateTime.UtcNow, new List()); - _mockGroupsRepo.Setup(r => r.Exists(groupId)).Returns(true); + _mockGroupsRepo.Setup(r => r.Exist(groupId)).Returns(true); _mockGroupsRepo.Setup(r => r.IsUserMemberOfGroupAsync(senderId, groupId)).ReturnsAsync(true); _mockMessagesRepo.Setup(r => r.AddAsync(It.IsAny())).Returns(Task.CompletedTask); diff --git a/Govor.Application/Services/MessageService.cs b/Govor.Application/Services/MessageService.cs index f894666..e39af2f 100644 --- a/Govor.Application/Services/MessageService.cs +++ b/Govor.Application/Services/MessageService.cs @@ -54,7 +54,7 @@ public class MessageService : IMessageService } else if (sendParams.RecipientType == RecipientType.Group) { - if (!_groupsRepository.Exists(sendParams.RecipientId)) + if (!_groupsRepository.Exist(sendParams.RecipientId)) { _logger.LogWarning("Attempt to send message to non-existent group {GroupId}", sendParams.RecipientId); return new SendMessageResult(false, new KeyNotFoundException($"Recipient group {sendParams.RecipientId} not found."), default); diff --git a/Govor.Core/Models/ChatGroup.cs b/Govor.Core/Models/ChatGroup.cs index 7954c97..beea1ee 100644 --- a/Govor.Core/Models/ChatGroup.cs +++ b/Govor.Core/Models/ChatGroup.cs @@ -11,4 +11,16 @@ public class ChatGroup public List Admins { get; set; } = new(); public List Members { get; set; } = new(); public List InviteCodes { get; set; } = new(); + + public override bool Equals(object? obj) + { + ChatGroup chatGroup = obj as ChatGroup; + + return Id == chatGroup.Id && + Name == chatGroup.Name && + Description == chatGroup.Description && + ImageId == chatGroup.ImageId && + IsChannel == chatGroup.IsChannel && + IsPrivate == chatGroup.IsPrivate; + } } \ No newline at end of file diff --git a/Govor.Core/Repositories/Groups/IGroupsExist.cs b/Govor.Core/Repositories/Groups/IGroupsExist.cs index 4e95afa..f6b763f 100644 --- a/Govor.Core/Repositories/Groups/IGroupsExist.cs +++ b/Govor.Core/Repositories/Groups/IGroupsExist.cs @@ -4,6 +4,6 @@ namespace Govor.Core.Repositories.Groups; public interface IGroupsExist { - public bool Exists(Guid groupId); - public bool Exists(ChatGroup chatGroup); + public bool Exist(Guid groupId); + public bool Exist(ChatGroup chatGroup); } \ No newline at end of file diff --git a/Govor.Data/Configurations/ChatGroupConfigurator.cs b/Govor.Data/Configurations/ChatGroupConfigurator.cs new file mode 100644 index 0000000..f2f5c90 --- /dev/null +++ b/Govor.Data/Configurations/ChatGroupConfigurator.cs @@ -0,0 +1,31 @@ +using Govor.Core.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Govor.Data.Configurations; + +public class ChatGroupConfigurator : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.Id); + + builder.Property(e => e.Name).IsRequired().HasMaxLength(100); + builder.Property(e => e.Description).HasMaxLength(500); + + builder.HasMany(e => e.Members) + .WithOne() + .HasForeignKey(e => e.GroupId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasMany(e => e.Admins) + .WithOne() + .HasForeignKey(e => e.GroupId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasMany(e => e.InviteCodes) + .WithOne() + .HasForeignKey(e => e.GroupId) + .OnDelete(DeleteBehavior.Cascade); + } +} \ No newline at end of file diff --git a/Govor.Data/Configurations/GroupAdminsConfiguration.cs b/Govor.Data/Configurations/GroupAdminsConfiguration.cs new file mode 100644 index 0000000..92866ff --- /dev/null +++ b/Govor.Data/Configurations/GroupAdminsConfiguration.cs @@ -0,0 +1,16 @@ +using Govor.Core.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Govor.Data.Configurations; + +public class GroupAdminsConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.Id); + + builder.Property(e => e.UserId).IsRequired(); + builder.Property(e => e.GroupId).IsRequired(); + } +} \ No newline at end of file diff --git a/Govor.Data/Configurations/GroupInvitationConfiguration.cs b/Govor.Data/Configurations/GroupInvitationConfiguration.cs new file mode 100644 index 0000000..4d92246 --- /dev/null +++ b/Govor.Data/Configurations/GroupInvitationConfiguration.cs @@ -0,0 +1,26 @@ +using Govor.Core.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Govor.Data.Configurations; + +public class GroupInvitationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.Id); + + builder.Property(e => e.InvitationCode).IsRequired().HasMaxLength(200); + builder.Property(e => e.Description).HasMaxLength(500); + builder.Property(e => e.EndDate).IsRequired(); + builder.Property(e => e.CreatedAt).IsRequired(); + + builder.HasOne(e => e.UserMaker) + .WithMany() + .HasForeignKey(e => e.UserMakerId) + .OnDelete(DeleteBehavior.Restrict); + + + builder.Ignore(e => e.GroupMemberships); + } +} \ No newline at end of file diff --git a/Govor.Data/Configurations/GroupMembershipConfiguration.cs b/Govor.Data/Configurations/GroupMembershipConfiguration.cs index 812aa8b..9cee30b 100644 --- a/Govor.Data/Configurations/GroupMembershipConfiguration.cs +++ b/Govor.Data/Configurations/GroupMembershipConfiguration.cs @@ -8,6 +8,16 @@ public class GroupMembershipConfiguration : IEntityTypeConfiguration builder) { - builder.HasKey(m => new { m.GroupId, m.UserId }); + builder.HasKey(e => e.Id); + + builder.Property(e => e.UserId).IsRequired(); + builder.Property(e => e.GroupId).IsRequired(); + builder.Property(e => e.InvitationId).IsRequired(); + + // Optional: можно добавить навигацию к GroupInvitation + builder.HasOne() + .WithMany() + .HasForeignKey(e => e.InvitationId) + .OnDelete(DeleteBehavior.SetNull); } } \ No newline at end of file diff --git a/Govor.Data/GovorDbContext.cs b/Govor.Data/GovorDbContext.cs index b49c3e8..5414310 100644 --- a/Govor.Data/GovorDbContext.cs +++ b/Govor.Data/GovorDbContext.cs @@ -36,6 +36,10 @@ public class GovorDbContext(DbContextOptions options) : DbContex modelBuilder.ApplyConfiguration(new MediaAttachmentsConfiguration()); modelBuilder.ApplyConfiguration(new MessageViewConfiguration()); modelBuilder.ApplyConfiguration(new MediaFileConfiguration()); + modelBuilder.ApplyConfiguration(new ChatGroupConfigurator()); + modelBuilder.ApplyConfiguration(new GroupInvitationConfiguration()); + modelBuilder.ApplyConfiguration(new GroupMembershipConfiguration()); + modelBuilder.ApplyConfiguration(new GroupAdminsConfiguration()); base.OnModelCreating(modelBuilder); } diff --git a/Govor.Data/Repositories/GroupRepository.cs b/Govor.Data/Repositories/GroupRepository.cs index daaf32a..f765551 100644 --- a/Govor.Data/Repositories/GroupRepository.cs +++ b/Govor.Data/Repositories/GroupRepository.cs @@ -139,12 +139,12 @@ public class GroupRepository : IGroupsRepository } } - public bool Exists(Guid groupId) + public bool Exist(Guid groupId) { return _context.ChatGroups.Any(g => g.Id == groupId); } - public bool Exists(ChatGroup chatGroup) + public bool Exist(ChatGroup chatGroup) { return _context.ChatGroups.Any(g => g.Id == chatGroup.Id && g.IsChannel == chatGroup.IsChannel && From 92c1ff645800f2b2075f01f9691bd0795cdd4573 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Mon, 7 Jul 2025 21:06:04 +0700 Subject: [PATCH 035/101] IUserGroupsService + tests --- .../Services/UserGroupsServiceTests.cs | 70 +++++++++++++++++++ .../ConfigurationProgramExtensions.cs | 1 + Govor.API/Hubs/ChatsHub.cs | 27 +++---- .../Interfaces/IUserGroupsService.cs | 8 +++ .../Services/UserGroupsService.cs | 28 ++++++++ 5 files changed, 121 insertions(+), 13 deletions(-) create mode 100644 Govor.API.Tests/UnitTests/Services/UserGroupsServiceTests.cs create mode 100644 Govor.Application/Interfaces/IUserGroupsService.cs create mode 100644 Govor.Application/Services/UserGroupsService.cs diff --git a/Govor.API.Tests/UnitTests/Services/UserGroupsServiceTests.cs b/Govor.API.Tests/UnitTests/Services/UserGroupsServiceTests.cs new file mode 100644 index 0000000..4849ed8 --- /dev/null +++ b/Govor.API.Tests/UnitTests/Services/UserGroupsServiceTests.cs @@ -0,0 +1,70 @@ +using AutoFixture; +using Govor.Application.Interfaces; +using Govor.Application.Services; +using Govor.Core.Models; +using Govor.Core.Repositories.Groups; +using Govor.Data.Repositories.Exceptions; +using Moq; + +namespace Govor.API.Tests.UnitTests.Services; + +[TestFixture] +public class UserGroupsServiceTests +{ + private Fixture _fixture; + private Mock _repositoryMock; + private IUserGroupsService _service; + + [SetUp] + public void SetUp() + { + _fixture = new Fixture(); + + _fixture.Behaviors + .OfType() + .ToList() + .ForEach(b => _fixture.Behaviors.Remove(b)); + + _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); + + _repositoryMock = new Mock(); + + _service = new UserGroupsService(_repositoryMock.Object); + } + + [Test] + public async Task GetUserGroups_ShouldReturnAllUserGroups() + { + // Arrange + var chats = _fixture.CreateMany(); + var userId = chats.First().Members.First().Id; + + _repositoryMock.Setup(r => r.GetByUserIdAsync(userId)) + .ReturnsAsync([chats.First()]); + + // Act + var result = await _service.GetUserGroupsAsync(userId); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.Count(), Is.EqualTo(1)); + Assert.That(result, Is.EquivalentTo([chats.First()])); + } + + [Test] + public async Task GetUserGroups_ButGroupDoesNotExist_ShouldReturnEmptyList() + { + // Arrange + var userId = _fixture.Create(); + + _repositoryMock.Setup(r => r.GetByUserIdAsync(userId)) + .ThrowsAsync(new NotFoundByKeyException(userId)); + + // Act + var result = await _service.GetUserGroupsAsync(userId); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.Count(), Is.EqualTo(0)); + } +} \ No newline at end of file diff --git a/Govor.API/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Extensions/ConfigurationProgramExtensions.cs index c727014..d9d49f8 100644 --- a/Govor.API/Extensions/ConfigurationProgramExtensions.cs +++ b/Govor.API/Extensions/ConfigurationProgramExtensions.cs @@ -52,6 +52,7 @@ public static class ConfigurationProgramExtensions services.AddScoped(); services.AddScoped(); + services.AddScoped(); } public static void AddRepositories(this IServiceCollection services) diff --git a/Govor.API/Hubs/ChatsHub.cs b/Govor.API/Hubs/ChatsHub.cs index 8c5eb3e..792c7f4 100644 --- a/Govor.API/Hubs/ChatsHub.cs +++ b/Govor.API/Hubs/ChatsHub.cs @@ -1,4 +1,5 @@ using Govor.API.Services; +using Govor.Application.Interfaces; using Govor.Application.Interfaces.Messages; using Govor.Application.Interfaces.Messages.Parameters; using Govor.Contracts.Requests.SignalR; @@ -14,6 +15,7 @@ public class ChatsHub : Hub { private readonly ILogger _logger; private readonly IMessageService _messageService; + private readonly IUserGroupsService _userService; public ChatsHub(ILogger logger, IMessageService messageService) { @@ -34,13 +36,12 @@ public class ChatsHub : Hub // Add user to their own group (for private messages and notifications) await Groups.AddToGroupAsync(Context.ConnectionId, userId.ToString()); _logger.LogInformation("User {UserId} connected with ConnectionId {ConnectionId} and added to their group", userId, Context.ConnectionId); - - // TODO: Add user to their chat groups - this might require fetching user's groups from a service - // var userGroups = await _userService.GetUserGroupsAsync(userId); - // foreach (var group in userGroups) - // { - // await Groups.AddToGroupAsync(Context.ConnectionId, $"group_{group.Id}"); - // } + + var userGroups = await _userService.GetUserGroupsAsync(userId); + foreach (var group in userGroups) + { + await Groups.AddToGroupAsync(Context.ConnectionId, $"group_{group.Id}"); + } await base.OnConnectedAsync(); } @@ -54,12 +55,12 @@ public class ChatsHub : Hub await Groups.RemoveFromGroupAsync(Context.ConnectionId, userId.ToString()); _logger.LogInformation("User {UserId} disconnected with ConnectionId {ConnectionId} and removed from their group", userId, Context.ConnectionId); - // TODO: Remove user from their chat groups - // var userGroups = await _userService.GetUserGroupsAsync(userId); // This might be problematic if the service relies on the user being connected - // foreach (var group in userGroups) - // { - // await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"group_{group.Id}"); - // } + + var userGroups = await _userService.GetUserGroupsAsync(userId); // This might be problematic if the service relies on the user being connected + foreach (var group in userGroups) + { + await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"group_{group.Id}"); + } } else if (exception != null) { diff --git a/Govor.Application/Interfaces/IUserGroupsService.cs b/Govor.Application/Interfaces/IUserGroupsService.cs new file mode 100644 index 0000000..1e51276 --- /dev/null +++ b/Govor.Application/Interfaces/IUserGroupsService.cs @@ -0,0 +1,8 @@ +using Govor.Core.Models; + +namespace Govor.Application.Interfaces; + +public interface IUserGroupsService +{ + Task> GetUserGroupsAsync(Guid userId); +} \ No newline at end of file diff --git a/Govor.Application/Services/UserGroupsService.cs b/Govor.Application/Services/UserGroupsService.cs new file mode 100644 index 0000000..04b04c0 --- /dev/null +++ b/Govor.Application/Services/UserGroupsService.cs @@ -0,0 +1,28 @@ +using Govor.Application.Interfaces; +using Govor.Core.Models; +using Govor.Core.Repositories.Groups; +using Govor.Data.Repositories.Exceptions; + +namespace Govor.Application.Services; + +public class UserGroupsService : IUserGroupsService +{ + private readonly IGroupsRepository _groupRep; + + public UserGroupsService(IGroupsRepository groupsRepository) + { + _groupRep = groupsRepository; + } + + public async Task> GetUserGroupsAsync(Guid userId) + { + try + { + return await _groupRep.GetByUserIdAsync(userId); + } + catch (NotFoundByKeyException ex) + { + return new List(); + } + } +} \ No newline at end of file From b1f3aa0266018dc33bd2996a4a7cfad226393f33 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Tue, 8 Jul 2025 22:28:04 +0700 Subject: [PATCH 036/101] rework and starts for new friends system --- .../IntegrationTests/Hubs/ChatsHubTests.cs | 12 +- .../FriendRequestServiceTests.cs} | 216 ++++++++---------- .../Friends/FriendshipServiceTests.cs | 157 +++++++++++++ .../Services/PingHandlerServiceTests.cs | 2 +- .../AdminStuff/FriendshipsController.cs | 1 - Govor.API/Controllers/ChatLoadController.cs | 16 ++ Govor.API/Controllers/FriendsController.cs | 49 +++- .../ConfigurationProgramExtensions.cs | 11 +- Govor.API/Govor.API.csproj | 4 + Govor.API/Hubs/ChatsHub.cs | 3 +- Govor.API/Hubs/FriendsHub.cs | 38 +++ Govor.API/Hubs/UsersHub.cs | 8 - .../Friends/IFriendRequestService.cs | 12 + .../Friends/IFriendsBlockService.cs | 7 + .../Interfaces/Friends/IFriendshipService.cs | 9 + .../Interfaces/IFriendsService.cs | 6 +- .../Interfaces/IMessagesLoader.cs | 9 + Govor.Application/Services/AuthService.cs | 4 +- .../FriendRequestService.cs} | 87 +++---- .../Services/Friends/FriendsBlockService.cs | 16 ++ .../Services/Friends/FriendshipService.cs | 64 ++++++ Govor.Application/Services/MessagesLoader.cs | 43 ++++ Govor.Console/Program.cs | 2 +- Govor.Contracts/DTOs/FriendshipDto.cs | 1 - Govor.Core/Models/GroupMembership.cs | 2 +- .../Groups/IGroupMessagesReader.cs | 2 +- ... 20250707142430_InitialCreate.Designer.cs} | 174 +++++++++++--- ...ate.cs => 20250707142430_InitialCreate.cs} | 210 ++++++++++++----- .../Migrations/GovorDbContextModelSnapshot.cs | 172 +++++++++++--- 29 files changed, 1028 insertions(+), 309 deletions(-) rename Govor.API.Tests/UnitTests/Services/{FriendsServiceTests.cs => Friends/FriendRequestServiceTests.cs} (66%) create mode 100644 Govor.API.Tests/UnitTests/Services/Friends/FriendshipServiceTests.cs create mode 100644 Govor.API/Controllers/ChatLoadController.cs create mode 100644 Govor.API/Hubs/FriendsHub.cs delete mode 100644 Govor.API/Hubs/UsersHub.cs create mode 100644 Govor.Application/Interfaces/Friends/IFriendRequestService.cs create mode 100644 Govor.Application/Interfaces/Friends/IFriendsBlockService.cs create mode 100644 Govor.Application/Interfaces/Friends/IFriendshipService.cs create mode 100644 Govor.Application/Interfaces/IMessagesLoader.cs rename Govor.Application/Services/{FriendsService.cs => Friends/FriendRequestService.cs} (56%) create mode 100644 Govor.Application/Services/Friends/FriendsBlockService.cs create mode 100644 Govor.Application/Services/Friends/FriendshipService.cs create mode 100644 Govor.Application/Services/MessagesLoader.cs rename Govor.Data/Migrations/{20250630111826_InitialCreate.Designer.cs => 20250707142430_InitialCreate.Designer.cs} (74%) rename Govor.Data/Migrations/{20250630111826_InitialCreate.cs => 20250707142430_InitialCreate.cs} (74%) diff --git a/Govor.API.Tests/IntegrationTests/Hubs/ChatsHubTests.cs b/Govor.API.Tests/IntegrationTests/Hubs/ChatsHubTests.cs index 4f2ebe1..01d0682 100644 --- a/Govor.API.Tests/IntegrationTests/Hubs/ChatsHubTests.cs +++ b/Govor.API.Tests/IntegrationTests/Hubs/ChatsHubTests.cs @@ -1,5 +1,6 @@ using AutoFixture; using Govor.API.Hubs; +using Govor.Application.Interfaces; using Govor.Application.Interfaces.Messages; using Microsoft.Extensions.Logging; using Moq; @@ -11,6 +12,7 @@ public class ChatsHubTests { private Mock> _loggerMock; private Mock _messageServiceMock; + private Mock _userGroupsServiceMock; private Fixture _fixture; private ChatsHub _chatsHub; @@ -22,14 +24,20 @@ public class ChatsHubTests _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); _messageServiceMock = new Mock(); + _userGroupsServiceMock = new Mock(); _loggerMock = new Mock>(); _chatsHub = new ChatsHub( _loggerMock.Object, - _messageServiceMock.Object + _messageServiceMock.Object, + _userGroupsServiceMock.Object ); } // Test for Send action - + [Test] + public void SendMessage_Success() + { + + } } \ No newline at end of file diff --git a/Govor.API.Tests/UnitTests/Services/FriendsServiceTests.cs b/Govor.API.Tests/UnitTests/Services/Friends/FriendRequestServiceTests.cs similarity index 66% rename from Govor.API.Tests/UnitTests/Services/FriendsServiceTests.cs rename to Govor.API.Tests/UnitTests/Services/Friends/FriendRequestServiceTests.cs index 79f6a6f..9f3f7a4 100644 --- a/Govor.API.Tests/UnitTests/Services/FriendsServiceTests.cs +++ b/Govor.API.Tests/UnitTests/Services/Friends/FriendRequestServiceTests.cs @@ -1,22 +1,22 @@ using AutoFixture; using Govor.Application.Exceptions.FriendsService; -using Govor.Application.Interfaces; -using Govor.Application.Services; +using Govor.Application.Interfaces.Friends; +using Govor.Application.Services.Friends; using Govor.Core.Models; using Govor.Core.Repositories.Friendships; using Govor.Core.Repositories.Users; using Govor.Data.Repositories.Exceptions; using Moq; -namespace Govor.API.Tests.UnitTests.Services; +namespace Govor.API.Tests.UnitTests.Services.Friends; [TestFixture] -public class FriendsServiceTests +public class FriendRequestServiceTests { private Fixture _fixture; private Mock _usersRepositoryMock; private Mock _friendshipsRepositoryMock; - private IFriendsService _service; + private IFriendRequestService _service; [SetUp] public void SetUp() @@ -31,96 +31,10 @@ public class FriendsServiceTests _usersRepositoryMock = new Mock(); _friendshipsRepositoryMock = new Mock(); - _service = new FriendsService(_usersRepositoryMock.Object, _friendshipsRepositoryMock.Object); - } - - // SearchUsersAsync - [Test] - public async Task SearchUsersAsync_ReturnsUsers_IfNotAlreadyFriends() - { - // Arrange - var userId = _fixture.Create(); - var user = _fixture.Create(); - user.Id = Guid.NewGuid(); - - _usersRepositoryMock - .Setup(u => u.SearchPotentialFriendsAsync(userId, It.IsAny())) - .ReturnsAsync(new List { user }); - - _friendshipsRepositoryMock - .Setup(f => f.FindByUserIdAsync(userId)) - .ReturnsAsync(new List()); // No friends - - // Act - var result = await _service.SearchUsersAsync("test", userId); - - // Assert - Assert.That(result, Has.Count.EqualTo(1)); - Assert.That(result[0].Id, Is.EqualTo(user.Id)); - } - - [Test] - public void SearchUsersAsync_Throws_SearchUsersException_IfThrowsNotFoundByKeyException_String_Guid() - { - // Arrange - _usersRepositoryMock - .Setup(u => u.SearchPotentialFriendsAsync(It.IsAny(), It.IsAny())) - .ThrowsAsync(new NotFoundByKeyException<(string,Guid)>(("test",Guid.NewGuid()),"test")); - - // Axt & Assert - Assert.ThrowsAsync(async () => await _service.SearchUsersAsync("test", Guid.NewGuid())); - } - - [Test] - public async Task SearchUsersAsync_ReturnsUsersWithoutFriendships_IfThrowsNotFoundByKeyException_Guid() - { - // Arrange - var userId = _fixture.Create(); - var user = _fixture.Create(); - user.Id = Guid.NewGuid(); - - _usersRepositoryMock - .Setup(u => u.SearchPotentialFriendsAsync(userId, It.IsAny())) - .ReturnsAsync(new List { user }); - - _friendshipsRepositoryMock - .Setup(f => f.FindByUserIdAsync(userId)) - .ThrowsAsync(new NotFoundByKeyException(userId, "test")); - - // Act - var result = await _service.SearchUsersAsync("test", userId); - - // Assert - Assert.That(result, Has.Count.EqualTo(1)); - Assert.That(result[0].Id, Is.EqualTo(user.Id)); - } - - [Test] - public void SearchUsersAsync_Throws_UnauthorizedAccessException_IfThrowsSomeExceptionInUsersRepository() - { - // Arrange - _usersRepositoryMock - .Setup(u => u.SearchPotentialFriendsAsync(It.IsAny(), It.IsAny())) - .ThrowsAsync(new Exception("test")); - - // Act & Assert - Assert.ThrowsAsync(async () => await _service.SearchUsersAsync("test", Guid.NewGuid())); - } - - [Test] - public void SearchUsersAsync_Throws_UnauthorizedAccessException_IfThrowsSomeExceptionInFriendshipsRepository() - { - // Arrange - _friendshipsRepositoryMock - .Setup(u => u.FindByUserIdAsync(It.IsAny())) - .ThrowsAsync(new Exception("test")); - - // Act & Assert - Assert.ThrowsAsync(async () => await _service.SearchUsersAsync("test", Guid.NewGuid())); + _service = new FriendRequestService(_friendshipsRepositoryMock.Object); } // SendFriendRequestAsync - [Test] public void SendFriendRequestAsync_SendingToSelf_ThrowsInvalidOperationException() { @@ -270,45 +184,104 @@ public class FriendsServiceTests Assert.That(updatedFriendship.Status, Is.EqualTo(FriendshipStatus.Accepted)); } - - // GetFriendsAsync + + // RejectFriend [Test] - public async Task GetFriendsAsync_ReturnsUsers_IfUserExists() + public async Task RejectFriendRequestAsync_ValidRequest_CallsRemoveAsync() { - // Arrange - var userId = Guid.NewGuid(); - var friendships = _fixture.CreateMany().ToList(); + var friendship = _fixture.Build() + .With(f => f.Status, FriendshipStatus.Pending) + .Create(); + + _friendshipsRepositoryMock + .Setup(r => r.GetByIdAsync(friendship.Id)) + .ReturnsAsync(friendship); - friendships.ForEach(f => - { - f.RequesterId = userId; - f.Status = FriendshipStatus.Accepted; - }); + await _service.RejectFriendRequestAsync(friendship.Id, friendship.AddresseeId); + + // Assert + _friendshipsRepositoryMock.Verify(r => r.UpdateAsync(It.Is(f => + f.Id == friendship.Id && + f.RequesterId == friendship.RequesterId && + f.AddresseeId == friendship.AddresseeId && + f.Status == FriendshipStatus.Rejected + )), Times.Once); + } + + [Test] + public void RejectFriendRequestAsync_Throws_InvalidOperationException_IfStatusNotEqualPendingOrReject() + { + var friendship = _fixture.Build() + .With(f => f.Status, FriendshipStatus.Accepted) + .Create(); - _friendshipsRepositoryMock.Setup(f => f.FindByUserIdAsync(userId)) - .ReturnsAsync(friendships); - - // Act - var result = await _service.GetFriendsAsync(userId); - - // Assert - Assert.That(result.Count, Is.EqualTo(friendships.Count)); - Assert.That(result, Is.EquivalentTo(friendships.Select(f => f.Addressee))); + _friendshipsRepositoryMock + .Setup(r => r.GetByIdAsync(friendship.Id)) + .ReturnsAsync(friendship); + + // Act & Assert + Assert.ThrowsAsync(async () => await _service.RejectFriendRequestAsync(friendship.Id, friendship.AddresseeId)); } [Test] - public void GetFriendsAsync_Throws_InvalidOperationException_IfUserNotExists() + public void RejectFriendRequestAsync_Throws_UnauthorizedAccessException_IfCurrentUserIdIsNotAddressee() { - // Arrange - _friendshipsRepositoryMock.Setup(f => f.FindByUserIdAsync(It.IsAny())) - .ThrowsAsync(new NotFoundByKeyException(Guid.NewGuid())); - - // Act & Assert - Assert.ThrowsAsync(async () => await _service.GetFriendsAsync(Guid.NewGuid())); + // Arrange + var friendship = _fixture.Build() + .With(f => f.Status, FriendshipStatus.Pending) + .Create(); + + _friendshipsRepositoryMock + .Setup(r => r.GetByIdAsync(friendship.Id)) + .ReturnsAsync(friendship); + + var wrongUserId = Guid.NewGuid(); // не совпадает с AddresseeId + + // Act & Assert + Assert.ThrowsAsync(async () => + await _service.RejectFriendRequestAsync(friendship.Id, wrongUserId)); + } + + [Test] + public void RejectFriendRequestAsync_Throws_InvalidOperationException_IfFriendshipNotFound() + { + // Arrange + var requestId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + + _friendshipsRepositoryMock + .Setup(r => r.GetByIdAsync(requestId)) + .ThrowsAsync(new NotFoundByKeyException(requestId)); + + // Act & Assert + Assert.ThrowsAsync(async () => + await _service.RejectFriendRequestAsync(requestId, userId)); + } + + [Test] + public async Task RejectFriendRequestAsync_ChangesStatusToAccepted() + { + var friendship = _fixture.Build() + .With(f => f.Status, FriendshipStatus.Pending) + .Create(); + + _friendshipsRepositoryMock + .Setup(r => r.GetByIdAsync(friendship.Id)) + .ReturnsAsync(friendship); + + Friendship updatedFriendship = null; + + _friendshipsRepositoryMock + .Setup(r => r.UpdateAsync(It.IsAny())) + .Callback(f => updatedFriendship = f) + .Returns(Task.CompletedTask); + + await _service.RejectFriendRequestAsync(friendship.Id, friendship.AddresseeId); + + Assert.That(updatedFriendship.Status, Is.EqualTo(FriendshipStatus.Rejected)); } // GetIncomingRequestsAsync - [Test] public async Task GetIncomingRequestsAsync_ReturnsFriendships_IfFriendshipsExists() { @@ -354,5 +327,4 @@ public class FriendsServiceTests Assert.ThrowsAsync(async () => await _service.GetIncomingRequestsAsync(userId)); } - -} +} \ No newline at end of file diff --git a/Govor.API.Tests/UnitTests/Services/Friends/FriendshipServiceTests.cs b/Govor.API.Tests/UnitTests/Services/Friends/FriendshipServiceTests.cs new file mode 100644 index 0000000..abb9205 --- /dev/null +++ b/Govor.API.Tests/UnitTests/Services/Friends/FriendshipServiceTests.cs @@ -0,0 +1,157 @@ +using AutoFixture; +using Govor.Application.Exceptions.FriendsService; +using Govor.Application.Interfaces.Friends; +using Govor.Application.Services.Friends; +using Govor.Core.Models; +using Govor.Core.Repositories.Friendships; +using Govor.Core.Repositories.Users; +using Govor.Data.Repositories.Exceptions; +using Moq; + +namespace Govor.API.Tests.UnitTests.Services.Friends; + +[TestFixture] +public class FriendshipServiceTests +{ + private Fixture _fixture; + private Mock _usersRepositoryMock; + private Mock _friendshipsRepositoryMock; + IFriendshipService _service; + + [SetUp] + public void SetUp() + { + _fixture = new Fixture(); + _fixture.Behaviors + .OfType() + .ToList() + .ForEach(b => _fixture.Behaviors.Remove(b)); + _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); + + _usersRepositoryMock = new Mock(); + _friendshipsRepositoryMock = new Mock(); + + _service = new FriendshipService(_usersRepositoryMock.Object, _friendshipsRepositoryMock.Object); + } + + // SearchUsersAsync + [Test] + public async Task SearchUsersAsync_ReturnsUsers_IfNotAlreadyFriends() + { + // Arrange + var userId = _fixture.Create(); + var user = _fixture.Create(); + user.Id = Guid.NewGuid(); + + _usersRepositoryMock + .Setup(u => u.SearchPotentialFriendsAsync(userId, It.IsAny())) + .ReturnsAsync(new List { user }); + + _friendshipsRepositoryMock + .Setup(f => f.FindByUserIdAsync(userId)) + .ReturnsAsync(new List()); // No friends + + // Act + var result = await _service.SearchUsersAsync("test", userId); + + // Assert + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result[0].Id, Is.EqualTo(user.Id)); + } + + [Test] + public void SearchUsersAsync_Throws_SearchUsersException_IfThrowsNotFoundByKeyException_String_Guid() + { + // Arrange + _usersRepositoryMock + .Setup(u => u.SearchPotentialFriendsAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new NotFoundByKeyException<(string,Guid)>(("test",Guid.NewGuid()),"test")); + + // Axt & Assert + Assert.ThrowsAsync(async () => await _service.SearchUsersAsync("test", Guid.NewGuid())); + } + + [Test] + public async Task SearchUsersAsync_ReturnsUsersWithoutFriendships_IfThrowsNotFoundByKeyException_Guid() + { + // Arrange + var userId = _fixture.Create(); + var user = _fixture.Create(); + user.Id = Guid.NewGuid(); + + _usersRepositoryMock + .Setup(u => u.SearchPotentialFriendsAsync(userId, It.IsAny())) + .ReturnsAsync(new List { user }); + + _friendshipsRepositoryMock + .Setup(f => f.FindByUserIdAsync(userId)) + .ThrowsAsync(new NotFoundByKeyException(userId, "test")); + + // Act + var result = await _service.SearchUsersAsync("test", userId); + + // Assert + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result[0].Id, Is.EqualTo(user.Id)); + } + + [Test] + public void SearchUsersAsync_Throws_UnauthorizedAccessException_IfThrowsSomeExceptionInUsersRepository() + { + // Arrange + _usersRepositoryMock + .Setup(u => u.SearchPotentialFriendsAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("test")); + + // Act & Assert + Assert.ThrowsAsync(async () => await _service.SearchUsersAsync("test", Guid.NewGuid())); + } + + [Test] + public void SearchUsersAsync_Throws_UnauthorizedAccessException_IfThrowsSomeExceptionInFriendshipsRepository() + { + // Arrange + _friendshipsRepositoryMock + .Setup(u => u.FindByUserIdAsync(It.IsAny())) + .ThrowsAsync(new Exception("test")); + + // Act & Assert + Assert.ThrowsAsync(async () => await _service.SearchUsersAsync("test", Guid.NewGuid())); + } + + // GetFriendsAsync + [Test] + public async Task GetFriendsAsync_ReturnsUsers_IfUserExists() + { + // Arrange + var userId = Guid.NewGuid(); + var friendships = _fixture.CreateMany().ToList(); + + friendships.ForEach(f => + { + f.RequesterId = userId; + f.Status = FriendshipStatus.Accepted; + }); + + _friendshipsRepositoryMock.Setup(f => f.FindByUserIdAsync(userId)) + .ReturnsAsync(friendships); + + // Act + var result = await _service.GetFriendsAsync(userId); + + // Assert + Assert.That(result.Count, Is.EqualTo(friendships.Count)); + Assert.That(result, Is.EquivalentTo(friendships.Select(f => f.Addressee))); + } + + [Test] + public void GetFriendsAsync_Throws_InvalidOperationException_IfUserNotExists() + { + // Arrange + _friendshipsRepositoryMock.Setup(f => f.FindByUserIdAsync(It.IsAny())) + .ThrowsAsync(new NotFoundByKeyException(Guid.NewGuid())); + + // Act & Assert + Assert.ThrowsAsync(async () => await _service.GetFriendsAsync(Guid.NewGuid())); + } +} \ No newline at end of file diff --git a/Govor.API.Tests/UnitTests/Services/PingHandlerServiceTests.cs b/Govor.API.Tests/UnitTests/Services/PingHandlerServiceTests.cs index 4510910..312342b 100644 --- a/Govor.API.Tests/UnitTests/Services/PingHandlerServiceTests.cs +++ b/Govor.API.Tests/UnitTests/Services/PingHandlerServiceTests.cs @@ -4,7 +4,7 @@ using Govor.Data; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; -namespace Govor.Tests.Application.Services; +namespace Govor.API.Tests.UnitTests.Services; [TestFixture] public class PingHandlerServiceTests diff --git a/Govor.API/Controllers/AdminStuff/FriendshipsController.cs b/Govor.API/Controllers/AdminStuff/FriendshipsController.cs index 94160e0..a124dbf 100644 --- a/Govor.API/Controllers/AdminStuff/FriendshipsController.cs +++ b/Govor.API/Controllers/AdminStuff/FriendshipsController.cs @@ -104,6 +104,5 @@ public class FriendshipsController : Controller Status = f.Status, AddresseeId = f.AddresseeId, RequesterId = f.RequesterId, - Requester = BuildUserDtos([f.Requester]).First(), }).ToList(); } \ No newline at end of file diff --git a/Govor.API/Controllers/ChatLoadController.cs b/Govor.API/Controllers/ChatLoadController.cs new file mode 100644 index 0000000..fd9a537 --- /dev/null +++ b/Govor.API/Controllers/ChatLoadController.cs @@ -0,0 +1,16 @@ +using Govor.Application.Interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Govor.API.Controllers; + +[ApiController] +[Authorize] +[Route("api/chats")] +public class ChatLoadController : Controller +{ + public ChatLoadController(ILogger logger, IMessagesLoader messagesLoader) + { + + } +} \ No newline at end of file diff --git a/Govor.API/Controllers/FriendsController.cs b/Govor.API/Controllers/FriendsController.cs index cfa6295..c333998 100644 --- a/Govor.API/Controllers/FriendsController.cs +++ b/Govor.API/Controllers/FriendsController.cs @@ -97,6 +97,26 @@ public class FriendsController : Controller } } + [HttpGet("responses")] + public async Task GetResponses() + { + try + { + var result = await _friendsService.GetResponsesAsync(_currentUserService.GetCurrentUserId()); + return Ok(BuildFriendshipDtos(result)); + } + catch (InvalidOperationException ex) + { + _logger.LogError(ex, ex.Message); + return Ok(Array.Empty()); + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + return StatusCode(500, new { error = "Internal server error." }); + } + } + [HttpPost("accept")] public async Task AcceptFriend([FromQuery] Guid friendshipId) { @@ -125,6 +145,34 @@ public class FriendsController : Controller } } + [HttpPost("reject")] + public async Task RejectFriend([FromQuery] Guid friendshipId) + { + if (friendshipId == Guid.Empty) + return BadRequest("Requester ID is invalid"); + + try + { + await _friendsService.RejectFriendRequestAsync(friendshipId, _currentUserService.GetCurrentUserId()); + return Ok(new { message = "Friend request rejected." }); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, ex.Message); + return NotFound(new { error = ex.Message }); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, ex.Message); + return Forbid(); + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + return StatusCode(500, new { error = "Failed to accept friend request." }); + } + } + [HttpGet] public async Task GetFriends() { @@ -160,6 +208,5 @@ public class FriendsController : Controller Status = f.Status, AddresseeId = f.AddresseeId, RequesterId = f.RequesterId, - Requester = BuildUserDtos([f.Requester]).First(), }).ToList(); } diff --git a/Govor.API/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Extensions/ConfigurationProgramExtensions.cs index d9d49f8..fc394e4 100644 --- a/Govor.API/Extensions/ConfigurationProgramExtensions.cs +++ b/Govor.API/Extensions/ConfigurationProgramExtensions.cs @@ -5,9 +5,11 @@ using Govor.Application.Infrastructure.Validators; using Govor.Application.Interfaces; using Govor.Application.Interfaces.AdminsStuff; using Govor.Application.Interfaces.Authentication; +using Govor.Application.Interfaces.Friends; using Govor.Application.Interfaces.Infrastructure.Extensions; using Govor.Application.Interfaces.Messages; using Govor.Application.Services; +using Govor.Application.Services.Friends; using Govor.Core.Infrastructure.Extensions; using Govor.Core.Infrastructure.Validators; using Govor.Core.Models; @@ -36,7 +38,11 @@ public static class ConfigurationProgramExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + + // Friends services + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(sp => { @@ -53,6 +59,7 @@ public static class ConfigurationProgramExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } public static void AddRepositories(this IServiceCollection services) @@ -65,6 +72,8 @@ public static class ConfigurationProgramExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + + // other } public static void AddValidators(this IServiceCollection services) diff --git a/Govor.API/Govor.API.csproj b/Govor.API/Govor.API.csproj index b88211f..3614dc4 100644 --- a/Govor.API/Govor.API.csproj +++ b/Govor.API/Govor.API.csproj @@ -23,5 +23,9 @@ + + + + diff --git a/Govor.API/Hubs/ChatsHub.cs b/Govor.API/Hubs/ChatsHub.cs index 792c7f4..0deabc4 100644 --- a/Govor.API/Hubs/ChatsHub.cs +++ b/Govor.API/Hubs/ChatsHub.cs @@ -17,10 +17,11 @@ public class ChatsHub : Hub private readonly IMessageService _messageService; private readonly IUserGroupsService _userService; - public ChatsHub(ILogger logger, IMessageService messageService) + public ChatsHub(ILogger logger, IMessageService messageService, IUserGroupsService userService) { _logger = logger; _messageService = messageService; + _userService = userService; } public override async Task OnConnectedAsync() diff --git a/Govor.API/Hubs/FriendsHub.cs b/Govor.API/Hubs/FriendsHub.cs new file mode 100644 index 0000000..d1a9b30 --- /dev/null +++ b/Govor.API/Hubs/FriendsHub.cs @@ -0,0 +1,38 @@ +using Govor.Application.Interfaces.Friends; +using Govor.Application.Interfaces.Infrastructure.Extensions; +using Microsoft.AspNetCore.SignalR; + +namespace Govor.API.Hubs; + +public class FriendsHub : Hub +{ + private readonly IFriendRequestService _friendRequestService; + private readonly ICurrentUserService _currentUserService; + + public FriendsHub(IFriendRequestService friendRequestService, ICurrentUserService currentUserService) + { + _friendRequestService = friendRequestService; + _currentUserService = currentUserService; + } + + public async Task SendRequest(Guid targetUserId) + { + var userId = _currentUserService.GetCurrentUserId(); + await _friendRequestService.SendFriendRequestAsync(userId, targetUserId); + await Clients.User(targetUserId.ToString()).SendAsync("FriendRequestReceived", userId); + } + + public async Task AcceptRequest(Guid friendshipId) + { + var userId = _currentUserService.GetCurrentUserId(); + await _friendRequestService.AcceptFriendRequestAsync(friendshipId, userId); + await Clients.User(userId.ToString()).SendAsync("FriendRequestAccepted", friendshipId); + } + + public async Task RejectRequest(Guid friendshipId) + { + var userId = _currentUserService.GetCurrentUserId(); + await _friendRequestService.RejectFriendRequestAsync(friendshipId, userId); + await Clients.User(userId.ToString()).SendAsync("FriendRequestRejected", friendshipId); + } +} \ No newline at end of file diff --git a/Govor.API/Hubs/UsersHub.cs b/Govor.API/Hubs/UsersHub.cs deleted file mode 100644 index 880aba6..0000000 --- a/Govor.API/Hubs/UsersHub.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Microsoft.AspNetCore.SignalR; - -namespace Govor.API.Hubs; - -public class UsersHub : Hub -{ - -} \ No newline at end of file diff --git a/Govor.Application/Interfaces/Friends/IFriendRequestService.cs b/Govor.Application/Interfaces/Friends/IFriendRequestService.cs new file mode 100644 index 0000000..d6d9e37 --- /dev/null +++ b/Govor.Application/Interfaces/Friends/IFriendRequestService.cs @@ -0,0 +1,12 @@ +using Govor.Core.Models; + +namespace Govor.Application.Interfaces.Friends; + +public interface IFriendRequestService +{ + Task SendFriendRequestAsync(Guid fromUserId, Guid toUserId); + Task AcceptFriendRequestAsync(Guid requestId, Guid currentUserId); + Task RejectFriendRequestAsync(Guid requestId, Guid currentUserId); + Task> GetIncomingRequestsAsync(Guid userId); + Task> GetResponsesAsync(Guid userId); +} \ No newline at end of file diff --git a/Govor.Application/Interfaces/Friends/IFriendsBlockService.cs b/Govor.Application/Interfaces/Friends/IFriendsBlockService.cs new file mode 100644 index 0000000..2d69160 --- /dev/null +++ b/Govor.Application/Interfaces/Friends/IFriendsBlockService.cs @@ -0,0 +1,7 @@ +namespace Govor.Application.Interfaces; + +public interface IFriendsBlockService +{ + Task BlockFriendRequestAsync(Guid userId, Guid currentUserId); + Task UnblockFriendRequestAsync(Guid userId, Guid currentUserId); +} \ No newline at end of file diff --git a/Govor.Application/Interfaces/Friends/IFriendshipService.cs b/Govor.Application/Interfaces/Friends/IFriendshipService.cs new file mode 100644 index 0000000..c791143 --- /dev/null +++ b/Govor.Application/Interfaces/Friends/IFriendshipService.cs @@ -0,0 +1,9 @@ +using Govor.Core.Models; + +namespace Govor.Application.Interfaces.Friends; + +public interface IFriendshipService +{ + Task> GetFriendsAsync(Guid userId); + Task> SearchUsersAsync(string query, Guid currentId); +} \ No newline at end of file diff --git a/Govor.Application/Interfaces/IFriendsService.cs b/Govor.Application/Interfaces/IFriendsService.cs index d234395..9cd4f71 100644 --- a/Govor.Application/Interfaces/IFriendsService.cs +++ b/Govor.Application/Interfaces/IFriendsService.cs @@ -7,6 +7,10 @@ public interface IFriendsService Task> SearchUsersAsync(string query, Guid currentId); Task SendFriendRequestAsync(Guid fromUserId, Guid toUserId); Task AcceptFriendRequestAsync(Guid requestId, Guid currentUserId); + Task RejectFriendRequestAsync(Guid requestId, Guid currentUserId); Task> GetFriendsAsync(Guid userId); + Task> GetResponsesAsync(Guid userId); Task> GetIncomingRequestsAsync(Guid userId); -} \ No newline at end of file +} + + diff --git a/Govor.Application/Interfaces/IMessagesLoader.cs b/Govor.Application/Interfaces/IMessagesLoader.cs new file mode 100644 index 0000000..1cc3334 --- /dev/null +++ b/Govor.Application/Interfaces/IMessagesLoader.cs @@ -0,0 +1,9 @@ +using Govor.Core.Models; + +namespace Govor.Application.Interfaces; + +public interface IMessagesLoader +{ + Task> LoadLastMessagesInUserChat(Guid userId,Guid currentId, Guid? startMessageId, int pageSize = 20); + Task> LoadLastMessagesInChatGroup(Guid chatId,Guid currentId, Guid? startMessageId, int pageSize = 20); +} \ No newline at end of file diff --git a/Govor.Application/Services/AuthService.cs b/Govor.Application/Services/AuthService.cs index e5682c2..f16bdb8 100644 --- a/Govor.Application/Services/AuthService.cs +++ b/Govor.Application/Services/AuthService.cs @@ -55,7 +55,7 @@ public class AuthService : IAccountService await _usersRepository.AddAsync(user); - SetRole(user, invitation); + await SetRole(user, invitation); return await _jwtService.GenerateJwtTokenAsync(user); } @@ -74,7 +74,7 @@ public class AuthService : IAccountService return await _jwtService.GenerateJwtTokenAsync(user); } - private async void SetRole(User user, Invitation invitation) + private async Task SetRole(User user, Invitation invitation) { if(invitation.IsAdmin) await _adminsRepository.AddAsync(new Admin() { UserId = user.Id }); diff --git a/Govor.Application/Services/FriendsService.cs b/Govor.Application/Services/Friends/FriendRequestService.cs similarity index 56% rename from Govor.Application/Services/FriendsService.cs rename to Govor.Application/Services/Friends/FriendRequestService.cs index 45b0de8..f174074 100644 --- a/Govor.Application/Services/FriendsService.cs +++ b/Govor.Application/Services/Friends/FriendRequestService.cs @@ -1,59 +1,29 @@ using Govor.Application.Exceptions.FriendsService; -using Govor.Application.Interfaces; +using Govor.Application.Interfaces.Friends; using Govor.Core.Models; using Govor.Core.Repositories.Friendships; -using Govor.Core.Repositories.Users; using Govor.Data.Repositories.Exceptions; -namespace Govor.Application.Services; +namespace Govor.Application.Services.Friends; -public class FriendsService : IFriendsService +public class FriendRequestService : IFriendRequestService { - private IUsersRepository _usersRepository; - private IFriendshipsRepository _friendshipsRepository; - - public FriendsService(IUsersRepository usersRepository, IFriendshipsRepository relationshipsRepository) - { - _usersRepository = usersRepository; - _friendshipsRepository = relationshipsRepository; - } - - public async Task> SearchUsersAsync(string query, Guid currentId) - { - List all = new List(); - - try - { - all = await _usersRepository.SearchPotentialFriendsAsync(currentId, query); + private readonly IFriendshipsRepository _friendshipsRepository; - return all - .Where(u => u.Id != currentId) - .ToList(); - } - catch (NotFoundByKeyException<(string, Guid)> ex) - { - throw new SearchUsersException( - $"Users with given query: \"{query}\" for user with id {currentId} was not found", ex); - } - catch (NotFoundByKeyException ex) - { - return all.Where(u => u.Id != currentId).ToList(); - } - catch (Exception ex) - { - throw new UnauthorizedAccessException($"When we try find friends by pattern {query} something wrong", ex); - } + public FriendRequestService(IFriendshipsRepository friendshipsRepository) + { + _friendshipsRepository = friendshipsRepository; } public async Task SendFriendRequestAsync(Guid fromUserId, Guid toUserId) { if (fromUserId == toUserId) throw new InvalidOperationException("Cannot send a request to self user"); - + if (_friendshipsRepository.Exist(fromUserId, toUserId)) throw new RequestAlreadySentException(fromUserId, toUserId); - - await _friendshipsRepository.AddAsync(new Friendship() + + await _friendshipsRepository.AddAsync(new Friendship { Id = Guid.NewGuid(), RequesterId = fromUserId, @@ -77,26 +47,30 @@ public class FriendsService : IFriendsService friendship.Status = FriendshipStatus.Accepted; await _friendshipsRepository.UpdateAsync(friendship); } - catch (NotFoundByKeyException e) + catch (NotFoundByKeyException ex) { - throw new InvalidOperationException("Friendship not found! You cant accept request!", e); + throw new InvalidOperationException("Friendship not found! You cant accept request!", ex); } } - public async Task> GetFriendsAsync(Guid userId) + public async Task RejectFriendRequestAsync(Guid requestId, Guid currentUserId) { try { - var friendships = await _friendshipsRepository.FindByUserIdAsync(userId); + var friendship = await _friendshipsRepository.GetByIdAsync(requestId); - return friendships - .Where(f => f.Status == FriendshipStatus.Accepted) - .Select(f => f.RequesterId == userId ? f.Addressee : f.Requester) - .ToList(); + if (friendship.AddresseeId != currentUserId) + throw new UnauthorizedAccessException("You cannot accept this request"); + + if (friendship.Status != FriendshipStatus.Pending && friendship.Status != FriendshipStatus.Rejected) + throw new InvalidOperationException($"Request is already {friendship.Status}"); + + friendship.Status = FriendshipStatus.Rejected; + await _friendshipsRepository.UpdateAsync(friendship); } catch (NotFoundByKeyException ex) { - throw new InvalidOperationException("User not found", ex); + throw new InvalidOperationException("Friendship not found! You cant reject request!", ex); } } @@ -113,5 +87,18 @@ public class FriendsService : IFriendsService throw new InvalidOperationException("User not exist", ex); } } -} + public async Task> GetResponsesAsync(Guid userId) + { + try + { + var friendships = await _friendshipsRepository.FindByUserIdAsync(userId); + return friendships.Where(f => f.RequesterId == userId && f.Status != FriendshipStatus.Accepted).ToList() + ?? new List(); + } + catch (NotFoundByKeyException ex) + { + throw new InvalidOperationException("User not exist", ex); + } + } +} \ No newline at end of file diff --git a/Govor.Application/Services/Friends/FriendsBlockService.cs b/Govor.Application/Services/Friends/FriendsBlockService.cs new file mode 100644 index 0000000..991f760 --- /dev/null +++ b/Govor.Application/Services/Friends/FriendsBlockService.cs @@ -0,0 +1,16 @@ +using Govor.Application.Interfaces; + +namespace Govor.Application.Services.Friends; + +public class FriendsBlockService : IFriendsBlockService +{ + public Task BlockFriendRequestAsync(Guid userId, Guid currentUserId) + { + throw new NotImplementedException(); + } + + public Task UnblockFriendRequestAsync(Guid userId, Guid currentUserId) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/Govor.Application/Services/Friends/FriendshipService.cs b/Govor.Application/Services/Friends/FriendshipService.cs new file mode 100644 index 0000000..522b2f5 --- /dev/null +++ b/Govor.Application/Services/Friends/FriendshipService.cs @@ -0,0 +1,64 @@ +using Govor.Application.Exceptions.FriendsService; +using Govor.Application.Interfaces.Friends; +using Govor.Core.Models; +using Govor.Core.Repositories.Friendships; +using Govor.Core.Repositories.Users; +using Govor.Data.Repositories.Exceptions; + +namespace Govor.Application.Services.Friends; + +public class FriendshipService : IFriendshipService +{ + private readonly IUsersRepository _usersRepository; + private readonly IFriendshipsRepository _friendshipsRepository; + + public FriendshipService(IUsersRepository usersRepository, IFriendshipsRepository friendshipsRepository) + { + _usersRepository = usersRepository; + _friendshipsRepository = friendshipsRepository; + } + + public async Task> SearchUsersAsync(string query, Guid currentId) + { + List all = new List(); + + try + { + all = await _usersRepository.SearchPotentialFriendsAsync(currentId, query); + + return all + .Where(u => u.Id != currentId) + .ToList(); + } + catch (NotFoundByKeyException<(string, Guid)> ex) + { + throw new SearchUsersException( + $"Users with given query: \"{query}\" for user with id {currentId} was not found", ex); + } + catch (NotFoundByKeyException ex) + { + return all.Where(u => u.Id != currentId).ToList(); + } + catch (Exception ex) + { + throw new UnauthorizedAccessException($"When we try find friends by pattern {query} something wrong", ex); + } + } + + public async Task> GetFriendsAsync(Guid userId) + { + try + { + var friendships = await _friendshipsRepository.FindByUserIdAsync(userId); + + return friendships + .Where(f => f.Status == FriendshipStatus.Accepted) + .Select(f => f.RequesterId == userId ? f.Addressee : f.Requester) + .ToList(); + } + catch (NotFoundByKeyException ex) + { + throw new InvalidOperationException("User not found", ex); + } + } +} \ No newline at end of file diff --git a/Govor.Application/Services/MessagesLoader.cs b/Govor.Application/Services/MessagesLoader.cs new file mode 100644 index 0000000..406a974 --- /dev/null +++ b/Govor.Application/Services/MessagesLoader.cs @@ -0,0 +1,43 @@ +using Govor.Application.Interfaces; +using Govor.Core.Models; +using Govor.Core.Repositories.Groups; +using Govor.Core.Repositories.Messages; +using Govor.Data.Repositories.Exceptions; + +namespace Govor.Application.Services; + +public class MessagesLoader : IMessagesLoader +{ + private IVerifyFriendship _friendship; + private IGroupsRepository _groupsRepository; + private IMessagesRepository _messagesRepository; + + public async Task> LoadLastMessagesInUserChat(Guid userId, Guid currentUser, Guid? startMessageId, int pageSize = 20) + { + await _friendship.VerifyAsync(userId, currentUser); + try + { + throw new NotImplementedException(); + //return await _groups.GetMessages(userId, startMessageId, pageSize); + } + catch (NotFoundException ex) + { + return new List(); + } + } + + public async Task> LoadLastMessagesInChatGroup(Guid chatId, Guid currentUser, Guid? startMessageId, int pageSize = 20) + { + if(!await _groupsRepository.IsUserMemberOfGroupAsync(currentUser, chatId)) + throw new UnauthorizedAccessException("You are not in a group."); + try + { + throw new NotImplementedException(); + //return await _groups.GetMessages(chatId, startMessageId, pageSize, RecipientType.Group); + } + catch (NotFoundException ex) + { + return new List(); + } + } +} \ No newline at end of file diff --git a/Govor.Console/Program.cs b/Govor.Console/Program.cs index 2a622ef..3383d4b 100644 --- a/Govor.Console/Program.cs +++ b/Govor.Console/Program.cs @@ -181,7 +181,7 @@ namespace Govor.ConsoleClient var requests = await friendsClient.GetIncomingRequestsAsync(); foreach (var r in requests) { - Console.WriteLine($"Запрос от: {r.Requester.Username}| {r.RequesterId} | Был онлайн: {r.Requester.WasOnline} (добавьте через /accept {r.Id})"); + Console.WriteLine($"Запрос от: {r.RequesterId} (добавьте через /accept {r.Id})"); } break; case "/chat": diff --git a/Govor.Contracts/DTOs/FriendshipDto.cs b/Govor.Contracts/DTOs/FriendshipDto.cs index f2b6ca1..85065e1 100644 --- a/Govor.Contracts/DTOs/FriendshipDto.cs +++ b/Govor.Contracts/DTOs/FriendshipDto.cs @@ -8,5 +8,4 @@ public class FriendshipDto public Guid RequesterId { get; set; } public Guid AddresseeId { get; set; } public FriendshipStatus Status { get; set; } - public UserDto Requester { get; set; } } \ No newline at end of file diff --git a/Govor.Core/Models/GroupMembership.cs b/Govor.Core/Models/GroupMembership.cs index d70341e..866cc59 100644 --- a/Govor.Core/Models/GroupMembership.cs +++ b/Govor.Core/Models/GroupMembership.cs @@ -5,6 +5,6 @@ public class GroupMembership public Guid Id { get; set; } public Guid GroupId { get; set; } public Guid UserId { get; set; } - public Guid InvitationId { get; set; } + public Guid? InvitationId { get; set; } public bool IsBanned { get; set; } } \ No newline at end of file diff --git a/Govor.Core/Repositories/Groups/IGroupMessagesReader.cs b/Govor.Core/Repositories/Groups/IGroupMessagesReader.cs index 3f5d9ab..aed1ffc 100644 --- a/Govor.Core/Repositories/Groups/IGroupMessagesReader.cs +++ b/Govor.Core/Repositories/Groups/IGroupMessagesReader.cs @@ -4,5 +4,5 @@ namespace Govor.Core.Repositories.Groups; public interface IGroupMessagesReader { - public Task> GetMessages(Guid chatId, Guid? startMessageId, int pageSize = 20); + public Task> GetMessages(Guid chatId, Guid? startMessageId, int pageSize = 20, RecipientType type = RecipientType.User); } \ No newline at end of file diff --git a/Govor.Data/Migrations/20250630111826_InitialCreate.Designer.cs b/Govor.Data/Migrations/20250707142430_InitialCreate.Designer.cs similarity index 74% rename from Govor.Data/Migrations/20250630111826_InitialCreate.Designer.cs rename to Govor.Data/Migrations/20250707142430_InitialCreate.Designer.cs index fc022ce..219d0c6 100644 --- a/Govor.Data/Migrations/20250630111826_InitialCreate.Designer.cs +++ b/Govor.Data/Migrations/20250707142430_InitialCreate.Designer.cs @@ -12,7 +12,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Govor.Data.Migrations { [DbContext(typeof(GovorDbContext))] - [Migration("20250630111826_InitialCreate")] + [Migration("20250707142430_InitialCreate")] partial class InitialCreate { /// @@ -20,7 +20,7 @@ namespace Govor.Data.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "9.0.6") + .HasAnnotation("ProductVersion", "8.0.6") .HasAnnotation("Relational:MaxIdentifierLength", 64); MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); @@ -41,13 +41,13 @@ namespace Govor.Data.Migrations .ValueGeneratedOnAdd() .HasColumnType("char(36)"); - b.PrimitiveCollection("Admins") + b.Property("Description") .IsRequired() - .HasColumnType("longtext"); + .HasMaxLength(500) + .HasColumnType("varchar(500)"); - b.PrimitiveCollection("InviteCode") - .IsRequired() - .HasColumnType("longtext"); + b.Property("ImageId") + .HasColumnType("char(36)"); b.Property("IsChannel") .HasColumnType("tinyint(1)"); @@ -57,7 +57,8 @@ namespace Govor.Data.Migrations b.Property("Name") .IsRequired() - .HasColumnType("longtext"); + .HasMaxLength(100) + .HasColumnType("varchar(100)"); b.HasKey("Id"); @@ -102,9 +103,51 @@ namespace Govor.Data.Migrations b.HasKey("Id"); + b.HasIndex("GroupId"); + b.ToTable("GroupAdmins"); }); + modelBuilder.Entity("Govor.Core.Models.GroupInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("EndDate") + .HasColumnType("datetime(6)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("InvitationCode") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("MaxParticipants") + .HasColumnType("int"); + + b.Property("UserMakerId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("UserMakerId"); + + b.ToTable("GroupInvitations"); + }); + modelBuilder.Entity("Govor.Core.Models.GroupMembership", b => { b.Property("Id") @@ -114,6 +157,10 @@ namespace Govor.Data.Migrations b.Property("GroupId") .HasColumnType("char(36)"); + b.Property("InvitationId") + .IsRequired() + .HasColumnType("char(36)"); + b.Property("IsBanned") .HasColumnType("tinyint(1)"); @@ -122,6 +169,10 @@ namespace Govor.Data.Migrations b.HasKey("Id"); + b.HasIndex("GroupId"); + + b.HasIndex("InvitationId"); + b.ToTable("GroupMemberships"); }); @@ -165,30 +216,46 @@ namespace Govor.Data.Migrations .ValueGeneratedOnAdd() .HasColumnType("char(36)"); - b.Property("EncryptedKey") - .HasMaxLength(512) - .HasColumnType("varchar(512)"); - - b.Property("FilePath") - .IsRequired() - .HasColumnType("longtext"); + b.Property("MediaFileId") + .HasColumnType("char(36)"); b.Property("MessageId") .HasColumnType("char(36)"); - b.Property("MimeType") + b.HasKey("Id"); + + b.HasIndex("MediaFileId"); + + b.HasIndex("MessageId"); + + b.ToTable("MediaAttachments"); + }); + + modelBuilder.Entity("Govor.Core.Models.MediaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("DateCreated") + .HasColumnType("datetime(6)"); + + b.Property("MediaType") .IsRequired() .HasColumnType("longtext"); - b.Property("Type") + b.Property("MineType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("Url") .IsRequired() .HasColumnType("longtext"); b.HasKey("Id"); - b.HasIndex("MessageId"); - - b.ToTable("MediaAttachments"); + b.ToTable("MediaFiles"); }); modelBuilder.Entity("Govor.Core.Models.Message", b => @@ -229,8 +296,6 @@ namespace Govor.Data.Migrations b.HasIndex("PrivateChatId"); - b.HasIndex("ReplyToMessageId"); - b.ToTable("Messages"); }); @@ -371,14 +436,63 @@ namespace Govor.Data.Migrations b.Navigation("Requester"); }); + modelBuilder.Entity("Govor.Core.Models.GroupAdmins", b => + { + b.HasOne("Govor.Core.Models.ChatGroup", null) + .WithMany("Admins") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupInvitation", b => + { + b.HasOne("Govor.Core.Models.ChatGroup", null) + .WithMany("InviteCodes") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Govor.Core.Models.User", "UserMaker") + .WithMany() + .HasForeignKey("UserMakerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("UserMaker"); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupMembership", b => + { + b.HasOne("Govor.Core.Models.ChatGroup", null) + .WithMany("Members") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Govor.Core.Models.GroupInvitation", null) + .WithMany() + .HasForeignKey("InvitationId") + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(); + }); + modelBuilder.Entity("Govor.Core.Models.MediaAttachments", b => { + b.HasOne("Govor.Core.Models.MediaFile", "MediaFile") + .WithMany() + .HasForeignKey("MediaFileId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + b.HasOne("Govor.Core.Models.Message", "Message") .WithMany("MediaAttachments") .HasForeignKey("MessageId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + b.Navigation("MediaFile"); + b.Navigation("Message"); }); @@ -387,13 +501,6 @@ namespace Govor.Data.Migrations b.HasOne("Govor.Core.Models.PrivateChat", null) .WithMany("Messages") .HasForeignKey("PrivateChatId"); - - b.HasOne("Govor.Core.Models.Message", "ReplyToMessage") - .WithMany() - .HasForeignKey("ReplyToMessageId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("ReplyToMessage"); }); modelBuilder.Entity("Govor.Core.Models.MessageReaction", b => @@ -435,6 +542,15 @@ namespace Govor.Data.Migrations b.Navigation("Invite"); }); + modelBuilder.Entity("Govor.Core.Models.ChatGroup", b => + { + b.Navigation("Admins"); + + b.Navigation("InviteCodes"); + + b.Navigation("Members"); + }); + modelBuilder.Entity("Govor.Core.Models.Invitation", b => { b.Navigation("Users"); diff --git a/Govor.Data/Migrations/20250630111826_InitialCreate.cs b/Govor.Data/Migrations/20250707142430_InitialCreate.cs similarity index 74% rename from Govor.Data/Migrations/20250630111826_InitialCreate.cs rename to Govor.Data/Migrations/20250707142430_InitialCreate.cs index 9fe9cbd..081bcc6 100644 --- a/Govor.Data/Migrations/20250630111826_InitialCreate.cs +++ b/Govor.Data/Migrations/20250707142430_InitialCreate.cs @@ -19,14 +19,13 @@ namespace Govor.Data.Migrations columns: table => new { Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), - Name = table.Column(type: "longtext", nullable: false) + Name = table.Column(type: "varchar(100)", maxLength: 100, nullable: false) .Annotation("MySql:CharSet", "utf8mb4"), - InviteCode = table.Column(type: "longtext", nullable: false) + Description = table.Column(type: "varchar(500)", maxLength: 500, nullable: false) .Annotation("MySql:CharSet", "utf8mb4"), + ImageId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), IsChannel = table.Column(type: "tinyint(1)", nullable: false), - IsPrivate = table.Column(type: "tinyint(1)", nullable: false), - Admins = table.Column(type: "longtext", nullable: false) - .Annotation("MySql:CharSet", "utf8mb4") + IsPrivate = table.Column(type: "tinyint(1)", nullable: false) }, constraints: table => { @@ -34,35 +33,6 @@ namespace Govor.Data.Migrations }) .Annotation("MySql:CharSet", "utf8mb4"); - migrationBuilder.CreateTable( - name: "GroupAdmins", - columns: table => new - { - Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), - GroupId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), - UserId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci") - }, - constraints: table => - { - table.PrimaryKey("PK_GroupAdmins", x => x.Id); - }) - .Annotation("MySql:CharSet", "utf8mb4"); - - migrationBuilder.CreateTable( - name: "GroupMemberships", - columns: table => new - { - Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), - GroupId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), - UserId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), - IsBanned = table.Column(type: "tinyint(1)", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_GroupMemberships", x => x.Id); - }) - .Annotation("MySql:CharSet", "utf8mb4"); - migrationBuilder.CreateTable( name: "Invitations", columns: table => new @@ -84,6 +54,25 @@ namespace Govor.Data.Migrations }) .Annotation("MySql:CharSet", "utf8mb4"); + migrationBuilder.CreateTable( + name: "MediaFiles", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + Url = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + MediaType = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + MineType = table.Column(type: "varchar(128)", maxLength: 128, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + DateCreated = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MediaFiles", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + migrationBuilder.CreateTable( name: "PrivateChats", columns: table => new @@ -98,6 +87,26 @@ namespace Govor.Data.Migrations }) .Annotation("MySql:CharSet", "utf8mb4"); + migrationBuilder.CreateTable( + name: "GroupAdmins", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + GroupId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + UserId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci") + }, + constraints: table => + { + table.PrimaryKey("PK_GroupAdmins", x => x.Id); + table.ForeignKey( + name: "FK_GroupAdmins_ChatGroups_GroupId", + column: x => x.GroupId, + principalTable: "ChatGroups", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + migrationBuilder.CreateTable( name: "Users", columns: table => new @@ -145,12 +154,6 @@ namespace Govor.Data.Migrations constraints: table => { table.PrimaryKey("PK_Messages", x => x.Id); - table.ForeignKey( - name: "FK_Messages_Messages_ReplyToMessageId", - column: x => x.ReplyToMessageId, - principalTable: "Messages", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); table.ForeignKey( name: "FK_Messages_PrivateChats_PrivateChatId", column: x => x.PrivateChatId, @@ -204,24 +207,56 @@ namespace Govor.Data.Migrations }) .Annotation("MySql:CharSet", "utf8mb4"); + migrationBuilder.CreateTable( + name: "GroupInvitations", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + GroupId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + UserMakerId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + InvitationCode = table.Column(type: "varchar(200)", maxLength: 200, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Description = table.Column(type: "varchar(500)", maxLength: 500, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + EndDate = table.Column(type: "datetime(6)", nullable: false), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + MaxParticipants = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_GroupInvitations", x => x.Id); + table.ForeignKey( + name: "FK_GroupInvitations_ChatGroups_GroupId", + column: x => x.GroupId, + principalTable: "ChatGroups", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_GroupInvitations_Users_UserMakerId", + column: x => x.UserMakerId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + migrationBuilder.CreateTable( name: "MediaAttachments", columns: table => new { Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), MessageId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), - Type = table.Column(type: "longtext", nullable: false) - .Annotation("MySql:CharSet", "utf8mb4"), - FilePath = table.Column(type: "longtext", nullable: false) - .Annotation("MySql:CharSet", "utf8mb4"), - MimeType = table.Column(type: "longtext", nullable: false) - .Annotation("MySql:CharSet", "utf8mb4"), - EncryptedKey = table.Column(type: "varchar(512)", maxLength: 512, nullable: true) - .Annotation("MySql:CharSet", "utf8mb4") + MediaFileId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci") }, constraints: table => { table.PrimaryKey("PK_MediaAttachments", x => x.Id); + table.ForeignKey( + name: "FK_MediaAttachments_MediaFiles_MediaFileId", + column: x => x.MediaFileId, + principalTable: "MediaFiles", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); table.ForeignKey( name: "FK_MediaAttachments_Messages_MessageId", column: x => x.MessageId, @@ -281,6 +316,34 @@ namespace Govor.Data.Migrations }) .Annotation("MySql:CharSet", "utf8mb4"); + migrationBuilder.CreateTable( + name: "GroupMemberships", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + GroupId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + UserId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + InvitationId = table.Column(type: "char(36)", nullable: true, collation: "ascii_general_ci"), + IsBanned = table.Column(type: "tinyint(1)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_GroupMemberships", x => x.Id); + table.ForeignKey( + name: "FK_GroupMemberships_ChatGroups_GroupId", + column: x => x.GroupId, + principalTable: "ChatGroups", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_GroupMemberships_GroupInvitations_InvitationId", + column: x => x.InvitationId, + principalTable: "GroupInvitations", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + migrationBuilder.CreateIndex( name: "IX_Friendships_AddresseeId", table: "Friendships", @@ -291,6 +354,36 @@ namespace Govor.Data.Migrations table: "Friendships", column: "RequesterId"); + migrationBuilder.CreateIndex( + name: "IX_GroupAdmins_GroupId", + table: "GroupAdmins", + column: "GroupId"); + + migrationBuilder.CreateIndex( + name: "IX_GroupInvitations_GroupId", + table: "GroupInvitations", + column: "GroupId"); + + migrationBuilder.CreateIndex( + name: "IX_GroupInvitations_UserMakerId", + table: "GroupInvitations", + column: "UserMakerId"); + + migrationBuilder.CreateIndex( + name: "IX_GroupMemberships_GroupId", + table: "GroupMemberships", + column: "GroupId"); + + migrationBuilder.CreateIndex( + name: "IX_GroupMemberships_InvitationId", + table: "GroupMemberships", + column: "InvitationId"); + + migrationBuilder.CreateIndex( + name: "IX_MediaAttachments_MediaFileId", + table: "MediaAttachments", + column: "MediaFileId"); + migrationBuilder.CreateIndex( name: "IX_MediaAttachments_MessageId", table: "MediaAttachments", @@ -312,11 +405,6 @@ namespace Govor.Data.Migrations table: "Messages", column: "PrivateChatId"); - migrationBuilder.CreateIndex( - name: "IX_Messages_ReplyToMessageId", - table: "Messages", - column: "ReplyToMessageId"); - migrationBuilder.CreateIndex( name: "IX_MessageViews_MessageId_UserId", table: "MessageViews", @@ -335,9 +423,6 @@ namespace Govor.Data.Migrations migrationBuilder.DropTable( name: "Admins"); - migrationBuilder.DropTable( - name: "ChatGroups"); - migrationBuilder.DropTable( name: "Friendships"); @@ -357,16 +442,25 @@ namespace Govor.Data.Migrations name: "MessageViews"); migrationBuilder.DropTable( - name: "Users"); + name: "GroupInvitations"); + + migrationBuilder.DropTable( + name: "MediaFiles"); migrationBuilder.DropTable( name: "Messages"); migrationBuilder.DropTable( - name: "Invitations"); + name: "ChatGroups"); + + migrationBuilder.DropTable( + name: "Users"); migrationBuilder.DropTable( name: "PrivateChats"); + + migrationBuilder.DropTable( + name: "Invitations"); } } } diff --git a/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs b/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs index 4b9f74d..13582b2 100644 --- a/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs +++ b/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ namespace Govor.Data.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "9.0.6") + .HasAnnotation("ProductVersion", "8.0.6") .HasAnnotation("Relational:MaxIdentifierLength", 64); MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); @@ -38,13 +38,13 @@ namespace Govor.Data.Migrations .ValueGeneratedOnAdd() .HasColumnType("char(36)"); - b.PrimitiveCollection("Admins") + b.Property("Description") .IsRequired() - .HasColumnType("longtext"); + .HasMaxLength(500) + .HasColumnType("varchar(500)"); - b.PrimitiveCollection("InviteCode") - .IsRequired() - .HasColumnType("longtext"); + b.Property("ImageId") + .HasColumnType("char(36)"); b.Property("IsChannel") .HasColumnType("tinyint(1)"); @@ -54,7 +54,8 @@ namespace Govor.Data.Migrations b.Property("Name") .IsRequired() - .HasColumnType("longtext"); + .HasMaxLength(100) + .HasColumnType("varchar(100)"); b.HasKey("Id"); @@ -99,9 +100,51 @@ namespace Govor.Data.Migrations b.HasKey("Id"); + b.HasIndex("GroupId"); + b.ToTable("GroupAdmins"); }); + modelBuilder.Entity("Govor.Core.Models.GroupInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("EndDate") + .HasColumnType("datetime(6)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("InvitationCode") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("MaxParticipants") + .HasColumnType("int"); + + b.Property("UserMakerId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("UserMakerId"); + + b.ToTable("GroupInvitations"); + }); + modelBuilder.Entity("Govor.Core.Models.GroupMembership", b => { b.Property("Id") @@ -111,6 +154,10 @@ namespace Govor.Data.Migrations b.Property("GroupId") .HasColumnType("char(36)"); + b.Property("InvitationId") + .IsRequired() + .HasColumnType("char(36)"); + b.Property("IsBanned") .HasColumnType("tinyint(1)"); @@ -119,6 +166,10 @@ namespace Govor.Data.Migrations b.HasKey("Id"); + b.HasIndex("GroupId"); + + b.HasIndex("InvitationId"); + b.ToTable("GroupMemberships"); }); @@ -162,30 +213,46 @@ namespace Govor.Data.Migrations .ValueGeneratedOnAdd() .HasColumnType("char(36)"); - b.Property("EncryptedKey") - .HasMaxLength(512) - .HasColumnType("varchar(512)"); - - b.Property("FilePath") - .IsRequired() - .HasColumnType("longtext"); + b.Property("MediaFileId") + .HasColumnType("char(36)"); b.Property("MessageId") .HasColumnType("char(36)"); - b.Property("MimeType") + b.HasKey("Id"); + + b.HasIndex("MediaFileId"); + + b.HasIndex("MessageId"); + + b.ToTable("MediaAttachments"); + }); + + modelBuilder.Entity("Govor.Core.Models.MediaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("DateCreated") + .HasColumnType("datetime(6)"); + + b.Property("MediaType") .IsRequired() .HasColumnType("longtext"); - b.Property("Type") + b.Property("MineType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("Url") .IsRequired() .HasColumnType("longtext"); b.HasKey("Id"); - b.HasIndex("MessageId"); - - b.ToTable("MediaAttachments"); + b.ToTable("MediaFiles"); }); modelBuilder.Entity("Govor.Core.Models.Message", b => @@ -226,8 +293,6 @@ namespace Govor.Data.Migrations b.HasIndex("PrivateChatId"); - b.HasIndex("ReplyToMessageId"); - b.ToTable("Messages"); }); @@ -368,14 +433,63 @@ namespace Govor.Data.Migrations b.Navigation("Requester"); }); + modelBuilder.Entity("Govor.Core.Models.GroupAdmins", b => + { + b.HasOne("Govor.Core.Models.ChatGroup", null) + .WithMany("Admins") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupInvitation", b => + { + b.HasOne("Govor.Core.Models.ChatGroup", null) + .WithMany("InviteCodes") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Govor.Core.Models.User", "UserMaker") + .WithMany() + .HasForeignKey("UserMakerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("UserMaker"); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupMembership", b => + { + b.HasOne("Govor.Core.Models.ChatGroup", null) + .WithMany("Members") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Govor.Core.Models.GroupInvitation", null) + .WithMany() + .HasForeignKey("InvitationId") + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(); + }); + modelBuilder.Entity("Govor.Core.Models.MediaAttachments", b => { + b.HasOne("Govor.Core.Models.MediaFile", "MediaFile") + .WithMany() + .HasForeignKey("MediaFileId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + b.HasOne("Govor.Core.Models.Message", "Message") .WithMany("MediaAttachments") .HasForeignKey("MessageId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + b.Navigation("MediaFile"); + b.Navigation("Message"); }); @@ -384,13 +498,6 @@ namespace Govor.Data.Migrations b.HasOne("Govor.Core.Models.PrivateChat", null) .WithMany("Messages") .HasForeignKey("PrivateChatId"); - - b.HasOne("Govor.Core.Models.Message", "ReplyToMessage") - .WithMany() - .HasForeignKey("ReplyToMessageId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("ReplyToMessage"); }); modelBuilder.Entity("Govor.Core.Models.MessageReaction", b => @@ -432,6 +539,15 @@ namespace Govor.Data.Migrations b.Navigation("Invite"); }); + modelBuilder.Entity("Govor.Core.Models.ChatGroup", b => + { + b.Navigation("Admins"); + + b.Navigation("InviteCodes"); + + b.Navigation("Members"); + }); + modelBuilder.Entity("Govor.Core.Models.Invitation", b => { b.Navigation("Users"); From 437bedb117f9b5789cc9b3e78d10bb4e6ce1cc9a Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Thu, 10 Jul 2025 15:31:57 +0700 Subject: [PATCH 037/101] Refactor friend request services and add SignalR error handling Split IFriendRequestService into command and query interfaces, refactor related services and tests, and update dependency injection. Add HubResult response model and a SignalR HubExceptionFilter for consistent error handling. Move and update FriendsHub to use new command service and result pattern. Update username validation to allow digits after Cyrillic letters. Add new controllers and configuration for SignalR. Remove obsolete IFriendRequestService and related code. --- ...cs => FriendRequestCommandServiceTests.cs} | 79 ++------- .../Friends/FriendRequestQueryServiceTests.cs | 130 +++++++++++++++ .../Validators/UsernameValidatorTests.cs | 2 +- .../{ => Friends}/FriendsController.cs | 0 .../Friends/FriendsRequestQueryController.cs | 12 ++ .../Friends/FriendshipController.cs | 6 + .../ConfigurationProgramExtensions.cs | 3 +- Govor.API/Extensions/ConfigurationSignalR.cs | 15 ++ Govor.API/Filters/HubExceptionFilter.cs | 76 +++++++++ Govor.API/Govor.API.csproj | 4 - Govor.API/Hubs/ChatsHub.cs | 152 +++++++++--------- Govor.API/Hubs/FriendsHub.cs | 103 ++++++++++-- Govor.API/Program.cs | 2 +- .../Validators/UsernameValidator.cs | 2 +- .../Friends/IFriendRequestCommandService.cs | 8 + .../Friends/IFriendRequestQueryService.cs | 10 ++ .../Friends/IFriendRequestService.cs | 12 -- ...vice.cs => FriendRequestCommandService.cs} | 40 +---- .../Friends/FriendRequestQueryService.cs | 44 +++++ .../Responses/SignalR/HubResult.cs | 76 +++++++++ 20 files changed, 571 insertions(+), 205 deletions(-) rename Govor.API.Tests/UnitTests/Services/Friends/{FriendRequestServiceTests.cs => FriendRequestCommandServiceTests.cs} (75%) create mode 100644 Govor.API.Tests/UnitTests/Services/Friends/FriendRequestQueryServiceTests.cs rename Govor.API/Controllers/{ => Friends}/FriendsController.cs (100%) create mode 100644 Govor.API/Controllers/Friends/FriendsRequestQueryController.cs create mode 100644 Govor.API/Controllers/Friends/FriendshipController.cs create mode 100644 Govor.API/Extensions/ConfigurationSignalR.cs create mode 100644 Govor.API/Filters/HubExceptionFilter.cs create mode 100644 Govor.Application/Interfaces/Friends/IFriendRequestCommandService.cs create mode 100644 Govor.Application/Interfaces/Friends/IFriendRequestQueryService.cs delete mode 100644 Govor.Application/Interfaces/Friends/IFriendRequestService.cs rename Govor.Application/Services/Friends/{FriendRequestService.cs => FriendRequestCommandService.cs} (63%) create mode 100644 Govor.Application/Services/Friends/FriendRequestQueryService.cs create mode 100644 Govor.Contracts/Responses/SignalR/HubResult.cs diff --git a/Govor.API.Tests/UnitTests/Services/Friends/FriendRequestServiceTests.cs b/Govor.API.Tests/UnitTests/Services/Friends/FriendRequestCommandServiceTests.cs similarity index 75% rename from Govor.API.Tests/UnitTests/Services/Friends/FriendRequestServiceTests.cs rename to Govor.API.Tests/UnitTests/Services/Friends/FriendRequestCommandServiceTests.cs index 9f3f7a4..2b2baa3 100644 --- a/Govor.API.Tests/UnitTests/Services/Friends/FriendRequestServiceTests.cs +++ b/Govor.API.Tests/UnitTests/Services/Friends/FriendRequestCommandServiceTests.cs @@ -11,12 +11,12 @@ using Moq; namespace Govor.API.Tests.UnitTests.Services.Friends; [TestFixture] -public class FriendRequestServiceTests +public class FriendRequestCommandServiceTests { private Fixture _fixture; private Mock _usersRepositoryMock; private Mock _friendshipsRepositoryMock; - private IFriendRequestService _service; + private IFriendRequestCommandService _service; [SetUp] public void SetUp() @@ -31,7 +31,7 @@ public class FriendRequestServiceTests _usersRepositoryMock = new Mock(); _friendshipsRepositoryMock = new Mock(); - _service = new FriendRequestService(_friendshipsRepositoryMock.Object); + _service = new FriendRequestCommandService(_friendshipsRepositoryMock.Object); } // SendFriendRequestAsync @@ -43,7 +43,7 @@ public class FriendRequestServiceTests // Act & Assert var ex = Assert.ThrowsAsync(() => - _service.SendFriendRequestAsync(userId, userId)); + _service.SendAsync(userId, userId)); Assert.That(ex.Message, Is.EqualTo("Cannot send a request to self user")); } @@ -61,7 +61,7 @@ public class FriendRequestServiceTests // Act & Assert Assert.ThrowsAsync(() => - _service.SendFriendRequestAsync(fromUserId, toUserId)); + _service.SendAsync(fromUserId, toUserId)); } [Test] @@ -76,7 +76,7 @@ public class FriendRequestServiceTests .Returns(false); // Act - await _service.SendFriendRequestAsync(fromUserId, toUserId); + await _service.SendAsync(fromUserId, toUserId); // Assert _friendshipsRepositoryMock.Verify(repo => @@ -102,7 +102,7 @@ public class FriendRequestServiceTests .ReturnsAsync(friendship); // Act: вызываем именно Accept, не Send - await _service.AcceptFriendRequestAsync(friendship.Id, friendship.AddresseeId); + await _service.AcceptAsync(friendship.Id, friendship.AddresseeId); // Assert _friendshipsRepositoryMock.Verify(r => r.UpdateAsync(It.Is(f => @@ -125,7 +125,7 @@ public class FriendRequestServiceTests .ReturnsAsync(friendship); // Act & Assert - Assert.ThrowsAsync(async () => await _service.AcceptFriendRequestAsync(friendship.Id, friendship.AddresseeId)); + Assert.ThrowsAsync(async () => await _service.AcceptAsync(friendship.Id, friendship.AddresseeId)); } [Test] public void AcceptFriendRequestAsync_Throws_UnauthorizedAccessException_IfCurrentUserIdIsNotAddressee() @@ -143,7 +143,7 @@ public class FriendRequestServiceTests // Act & Assert Assert.ThrowsAsync(async () => - await _service.AcceptFriendRequestAsync(friendship.Id, wrongUserId)); + await _service.AcceptAsync(friendship.Id, wrongUserId)); } [Test] @@ -159,7 +159,7 @@ public class FriendRequestServiceTests // Act & Assert Assert.ThrowsAsync(async () => - await _service.AcceptFriendRequestAsync(requestId, userId)); + await _service.AcceptAsync(requestId, userId)); } [Test] @@ -180,7 +180,7 @@ public class FriendRequestServiceTests .Callback(f => updatedFriendship = f) .Returns(Task.CompletedTask); - await _service.AcceptFriendRequestAsync(friendship.Id, friendship.AddresseeId); + await _service.AcceptAsync(friendship.Id, friendship.AddresseeId); Assert.That(updatedFriendship.Status, Is.EqualTo(FriendshipStatus.Accepted)); } @@ -197,7 +197,7 @@ public class FriendRequestServiceTests .Setup(r => r.GetByIdAsync(friendship.Id)) .ReturnsAsync(friendship); - await _service.RejectFriendRequestAsync(friendship.Id, friendship.AddresseeId); + await _service.RejectAsync(friendship.Id, friendship.AddresseeId); // Assert _friendshipsRepositoryMock.Verify(r => r.UpdateAsync(It.Is(f => @@ -220,7 +220,7 @@ public class FriendRequestServiceTests .ReturnsAsync(friendship); // Act & Assert - Assert.ThrowsAsync(async () => await _service.RejectFriendRequestAsync(friendship.Id, friendship.AddresseeId)); + Assert.ThrowsAsync(async () => await _service.RejectAsync(friendship.Id, friendship.AddresseeId)); } [Test] @@ -239,7 +239,7 @@ public class FriendRequestServiceTests // Act & Assert Assert.ThrowsAsync(async () => - await _service.RejectFriendRequestAsync(friendship.Id, wrongUserId)); + await _service.RejectAsync(friendship.Id, wrongUserId)); } [Test] @@ -255,7 +255,7 @@ public class FriendRequestServiceTests // Act & Assert Assert.ThrowsAsync(async () => - await _service.RejectFriendRequestAsync(requestId, userId)); + await _service.RejectAsync(requestId, userId)); } [Test] @@ -276,55 +276,10 @@ public class FriendRequestServiceTests .Callback(f => updatedFriendship = f) .Returns(Task.CompletedTask); - await _service.RejectFriendRequestAsync(friendship.Id, friendship.AddresseeId); + await _service.RejectAsync(friendship.Id, friendship.AddresseeId); Assert.That(updatedFriendship.Status, Is.EqualTo(FriendshipStatus.Rejected)); } - // GetIncomingRequestsAsync - [Test] - public async Task GetIncomingRequestsAsync_ReturnsFriendships_IfFriendshipsExists() - { - // Arrange - var userId = Guid.NewGuid(); - - var friendships = _fixture.CreateMany().ToList(); - - var user = _fixture.Build() - .With(u => u.Id, userId) - .With(u => u.ReceivedFriendRequests, friendships) - .Create(); - - friendships.ForEach(f => - { - f.AddresseeId = userId; - f.Addressee = user; - f.Status = FriendshipStatus.Pending; - }); - - _friendshipsRepositoryMock.Setup(f => f.FindByUserIdAsync(userId)) - .ReturnsAsync(friendships); - - // Act - var result = await _service.GetIncomingRequestsAsync(userId); - - // Assert - Assert.That(result.Count, Is.EqualTo(friendships.Count)); - Assert.That(result.Select(u => u.Id), Is.EquivalentTo(friendships.Select(f => f.Id))); - } - - [Test] - public void GetIncomingRequestsAsync_ThrowsInvalidOperationException_WhenUserNotFound() - { - // Arrange - var userId = Guid.NewGuid(); - - _friendshipsRepositoryMock - .Setup(r => r.FindByUserIdAsync(userId)) - .ThrowsAsync(new NotFoundByKeyException(userId)); - - // Act & Assert - Assert.ThrowsAsync(async () => - await _service.GetIncomingRequestsAsync(userId)); - } + } \ No newline at end of file diff --git a/Govor.API.Tests/UnitTests/Services/Friends/FriendRequestQueryServiceTests.cs b/Govor.API.Tests/UnitTests/Services/Friends/FriendRequestQueryServiceTests.cs new file mode 100644 index 0000000..7870a5b --- /dev/null +++ b/Govor.API.Tests/UnitTests/Services/Friends/FriendRequestQueryServiceTests.cs @@ -0,0 +1,130 @@ +using AutoFixture; +using Govor.Application.Interfaces.Friends; +using Govor.Application.Services.Friends; +using Govor.Core.Models; +using Govor.Core.Repositories.Friendships; +using Govor.Core.Repositories.Users; +using Govor.Data.Repositories.Exceptions; +using Moq; + +namespace Govor.API.Tests.UnitTests.Services.Friends; + +[TestFixture] +public class FriendRequestQueryServiceTests +{ + private Fixture _fixture; + private Mock _usersRepositoryMock; + private Mock _friendshipsRepositoryMock; + private IFriendRequestQueryService _service; + + [SetUp] + public void SetUp() + { + _fixture = new Fixture(); + _fixture.Behaviors + .OfType() + .ToList() + .ForEach(b => _fixture.Behaviors.Remove(b)); + _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); + + _usersRepositoryMock = new Mock(); + _friendshipsRepositoryMock = new Mock(); + + _service = new FriendRequestQueryService(_friendshipsRepositoryMock.Object); + } + + // GetIncomingRequestsAsync + [Test] + public async Task GetIncomingRequestsAsync_ReturnsFriendships_IfFriendshipsExists() + { + // Arrange + var userId = Guid.NewGuid(); + + var friendships = _fixture.CreateMany().ToList(); + + var user = _fixture.Build() + .With(u => u.Id, userId) + .With(u => u.ReceivedFriendRequests, friendships) + .Create(); + + friendships.ForEach(f => + { + f.AddresseeId = userId; + f.Addressee = user; + f.Status = FriendshipStatus.Pending; + }); + + _friendshipsRepositoryMock.Setup(f => f.FindByUserIdAsync(userId)) + .ReturnsAsync(friendships); + + // Act + var result = await _service.GetIncomingAsync(userId); + + // Assert + Assert.That(result.Count, Is.EqualTo(friendships.Count)); + Assert.That(result.Select(u => u.Id), Is.EquivalentTo(friendships.Select(f => f.Id))); + } + + [Test] + public void GetIncomingRequestsAsync_ThrowsInvalidOperationException_WhenUserNotFound() + { + // Arrange + var userId = Guid.NewGuid(); + + _friendshipsRepositoryMock + .Setup(r => r.FindByUserIdAsync(userId)) + .ThrowsAsync(new NotFoundByKeyException(userId)); + + // Act & Assert + Assert.ThrowsAsync(async () => + await _service.GetIncomingAsync(userId)); + } + + // GetResponsesAsync + [Test] + public async Task GetResponsesAsync_ReturnsFriendships_IfFriendshipsExists() + { + // Arrange + var userId = Guid.NewGuid(); + + var friendships = _fixture.CreateMany().ToList(); + + var user = _fixture.Build() + .With(u => u.Id, userId) + .With(u => u.ReceivedFriendRequests, friendships) + .Create(); + + friendships.ForEach(f => + { + f.RequesterId = userId; + f.Requester = user; + f.Status = FriendshipStatus.Rejected; + }); + + _friendshipsRepositoryMock.Setup(f => f.FindByUserIdAsync(userId)) + .ReturnsAsync(friendships); + + // Act + var result = await _service.GetResponsesAsync(userId); + + // Assert + Assert.That(result.Count, Is.EqualTo(friendships.Count)); + Assert.That(result.Select(u => u.Id), Is.EquivalentTo(friendships.Select(f => f.Id))); + } + + [Test] + public void GetResponsesAsync_ThrowsInvalidOperationException_WhenUserNotFound() + { + // Arrange + var userId = Guid.NewGuid(); + + _friendshipsRepositoryMock + .Setup(r => r.FindByUserIdAsync(userId)) + .ThrowsAsync(new NotFoundByKeyException(userId)); + + // Act & Assert + Assert.ThrowsAsync(async () => + await _service.GetResponsesAsync(userId)); + } + +} \ No newline at end of file diff --git a/Govor.API.Tests/UnitTests/Services/Validators/UsernameValidatorTests.cs b/Govor.API.Tests/UnitTests/Services/Validators/UsernameValidatorTests.cs index 991319f..1e4571a 100644 --- a/Govor.API.Tests/UnitTests/Services/Validators/UsernameValidatorTests.cs +++ b/Govor.API.Tests/UnitTests/Services/Validators/UsernameValidatorTests.cs @@ -17,6 +17,7 @@ public class UsernameValidatorTests [TestCase("Иван")] [TestCase("Алексей")] [TestCase("Ёжик")] + [TestCase("Иван123")] // содержит цифры public void Validate_ValidUsernames_ShouldNotThrow(string username) { Assert.DoesNotThrow(() => _validator.Validate(username)); @@ -24,7 +25,6 @@ public class UsernameValidatorTests [TestCase("Ivan")] // не кириллица [TestCase("123Иван")] // начинается не с буквы - [TestCase("Иван123")] // содержит цифры [TestCase("!@#$")] // спецсимволы [TestCase("")] // пусто [TestCase("И")] // меньше минимума diff --git a/Govor.API/Controllers/FriendsController.cs b/Govor.API/Controllers/Friends/FriendsController.cs similarity index 100% rename from Govor.API/Controllers/FriendsController.cs rename to Govor.API/Controllers/Friends/FriendsController.cs diff --git a/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs b/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs new file mode 100644 index 0000000..0863894 --- /dev/null +++ b/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Govor.API.Controllers.Friends; + +[ApiController] +[Authorize] +[Route("api/friends")] +public class FriendsRequestQueryController : Controller +{ + +} \ No newline at end of file diff --git a/Govor.API/Controllers/Friends/FriendshipController.cs b/Govor.API/Controllers/Friends/FriendshipController.cs new file mode 100644 index 0000000..fbfe156 --- /dev/null +++ b/Govor.API/Controllers/Friends/FriendshipController.cs @@ -0,0 +1,6 @@ +namespace Govor.API.Controllers.Friends; + +public class FriendshipController +{ + +} \ No newline at end of file diff --git a/Govor.API/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Extensions/ConfigurationProgramExtensions.cs index fc394e4..88ec6d8 100644 --- a/Govor.API/Extensions/ConfigurationProgramExtensions.cs +++ b/Govor.API/Extensions/ConfigurationProgramExtensions.cs @@ -41,7 +41,8 @@ public static class ConfigurationProgramExtensions // Friends services services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(sp => diff --git a/Govor.API/Extensions/ConfigurationSignalR.cs b/Govor.API/Extensions/ConfigurationSignalR.cs new file mode 100644 index 0000000..69cbe0d --- /dev/null +++ b/Govor.API/Extensions/ConfigurationSignalR.cs @@ -0,0 +1,15 @@ +using Govor.API.Filters; +using Microsoft.AspNetCore.SignalR; + +namespace Govor.API.Extensions; + +public static class ConfigurationSignalR +{ + public static void AddSignalRConf(this IServiceCollection services) + { + services.AddSignalR(options => + { + options.AddFilter(); + }); + } +} \ No newline at end of file diff --git a/Govor.API/Filters/HubExceptionFilter.cs b/Govor.API/Filters/HubExceptionFilter.cs new file mode 100644 index 0000000..fa814fb --- /dev/null +++ b/Govor.API/Filters/HubExceptionFilter.cs @@ -0,0 +1,76 @@ +using Govor.Contracts.Responses.SignalR; +using Microsoft.AspNetCore.SignalR; + +namespace Govor.API.Filters; + +public class HubExceptionFilter : IHubFilter +{ + private readonly ILogger _logger; + + public HubExceptionFilter(ILogger logger) + { + _logger = logger; + } + + public async ValueTask InvokeMethodAsync( + HubInvocationContext context, + Func> next) + { + try + { + return await next(context); + } + catch (ArgumentException ex) + { + _logger.LogWarning(ex, "Bad request in {Method}", context.HubMethodName); + return CreateHubErrorResult(context, HubResultStatus.BadRequest, ex.Message); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, "Unauthorized in {Method}", context.HubMethodName); + return CreateHubErrorResult(context, HubResultStatus.Unauthorized, ex.Message); + } + catch (KeyNotFoundException ex) + { + _logger.LogWarning(ex, "Not found in {Method}", context.HubMethodName); + return CreateHubErrorResult(context, HubResultStatus.NotFound, ex.Message); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "Invalid operation in {Method}", context.HubMethodName); + return CreateHubErrorResult(context, HubResultStatus.Conflict, ex.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unhandled error in {Method}", context.HubMethodName); + return CreateHubErrorResult(context, HubResultStatus.ServerError, "Internal server error"); + } + } + + private static object CreateHubErrorResult(HubInvocationContext context, HubResultStatus status, string message) + { + var returnType = context.HubMethod.ReturnType; + + // Поддержка Task> + if (returnType.IsGenericType && returnType.GetGenericTypeDefinition() == typeof(Task<>)) + { + var resultType = returnType.GenericTypeArguments[0]; + + if (resultType.IsGenericType && resultType.GetGenericTypeDefinition() == typeof(HubResult<>)) + { + var hubResultType = resultType; + var errorResult = Activator.CreateInstance(hubResultType)!; + + hubResultType.GetProperty(nameof(HubResult.Status))! + .SetValue(errorResult, status); + hubResultType.GetProperty(nameof(HubResult.ErrorMessage))! + .SetValue(errorResult, message); + + return Task.FromResult(errorResult); + } + } + + // Ничего не возвращаем, если не поддерживается + return null; + } +} \ No newline at end of file diff --git a/Govor.API/Govor.API.csproj b/Govor.API/Govor.API.csproj index 3614dc4..b88211f 100644 --- a/Govor.API/Govor.API.csproj +++ b/Govor.API/Govor.API.csproj @@ -23,9 +23,5 @@ - - - - diff --git a/Govor.API/Hubs/ChatsHub.cs b/Govor.API/Hubs/ChatsHub.cs index 0deabc4..f8e380b 100644 --- a/Govor.API/Hubs/ChatsHub.cs +++ b/Govor.API/Hubs/ChatsHub.cs @@ -16,14 +16,14 @@ public class ChatsHub : Hub private readonly ILogger _logger; private readonly IMessageService _messageService; private readonly IUserGroupsService _userService; - + public ChatsHub(ILogger logger, IMessageService messageService, IUserGroupsService userService) { _logger = logger; _messageService = messageService; _userService = userService; } - + public override async Task OnConnectedAsync() { var userId = GetUserId(); @@ -36,28 +36,34 @@ public class ChatsHub : Hub // Add user to their own group (for private messages and notifications) await Groups.AddToGroupAsync(Context.ConnectionId, userId.ToString()); - _logger.LogInformation("User {UserId} connected with ConnectionId {ConnectionId} and added to their group", userId, Context.ConnectionId); - + _logger.LogInformation("User {UserId} connected with ConnectionId {ConnectionId} and added to their group", + userId, Context.ConnectionId); + var userGroups = await _userService.GetUserGroupsAsync(userId); foreach (var group in userGroups) { - await Groups.AddToGroupAsync(Context.ConnectionId, $"group_{group.Id}"); + await Groups.AddToGroupAsync(Context.ConnectionId, $"group_{group.Id}"); } await base.OnConnectedAsync(); } - + public override async Task OnDisconnectedAsync(Exception? exception) { - var userId = GetUserId(suppressException: true); // Suppress exception if userID is not found (e.g. connection aborted early) + var userId = + GetUserId(suppressException: true); // Suppress exception if userID is not found (e.g. connection aborted early) if (userId != Guid.Empty) { // Remove user from their own group await Groups.RemoveFromGroupAsync(Context.ConnectionId, userId.ToString()); - _logger.LogInformation("User {UserId} disconnected with ConnectionId {ConnectionId} and removed from their group", userId, Context.ConnectionId); + _logger.LogInformation( + "User {UserId} disconnected with ConnectionId {ConnectionId} and removed from their group", userId, + Context.ConnectionId); - - var userGroups = await _userService.GetUserGroupsAsync(userId); // This might be problematic if the service relies on the user being connected + var userGroups = + await _userService + .GetUserGroupsAsync( + userId); // This might be problematic if the service relies on the user being connected foreach (var group in userGroups) { await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"group_{group.Id}"); @@ -65,11 +71,15 @@ public class ChatsHub : Hub } else if (exception != null) { - _logger.LogWarning(exception, "User disconnected with an exception and invalid UserID claim. ConnectionId: {ConnectionId}", Context.ConnectionId); + _logger.LogWarning(exception, + "User disconnected with an exception and invalid UserID claim. ConnectionId: {ConnectionId}", + Context.ConnectionId); } else { - _logger.LogInformation("User disconnected with no exception and invalid UserID claim. ConnectionId: {ConnectionId}", Context.ConnectionId); + _logger.LogInformation( + "User disconnected with no exception and invalid UserID claim. ConnectionId: {ConnectionId}", + Context.ConnectionId); } await base.OnDisconnectedAsync(exception); @@ -77,88 +87,78 @@ public class ChatsHub : Hub public async Task Send(MessageRequest request) { - if (string.IsNullOrWhiteSpace(request.EncryptedContent) && (request.MediaAttachments == null || !request.MediaAttachments.Any())) + if (string.IsNullOrWhiteSpace(request.EncryptedContent) && + (request.MediaAttachments == null || !request.MediaAttachments.Any())) { - _logger.LogWarning("Empty message (no content and no attachments) received from user {UserId}", GetUserId()); + _logger.LogWarning("Empty message (no content and no attachments) received from user {UserId}", + GetUserId()); throw new ArgumentException("Message cannot be empty (must have content or attachments)."); } var senderId = GetUserId(); - _logger.LogInformation("Message send initiated by {SenderId} to {RecipientId} of type {RecipientType}", senderId, request.RecipientId, request.RecipientType); + _logger.LogInformation("Message send initiated by {SenderId} to {RecipientId} of type {RecipientType}", + senderId, request.RecipientId, request.RecipientType); - var sendMessageParams = new SendMessage( - EncryptContent: request.EncryptedContent, - ReplyToMessageId: request.ReplyToMessageId, - FromUserId: senderId, - RecipientId: request.RecipientId, - RecipientType: request.RecipientType, - SendAt: DateTime.UtcNow, - Media: request.MediaAttachments?.Select(f => new SendMedia( - f.MediaId, f.EncryptedKey)) ?? Array.Empty() - ); + var sendMessageParams = new SendMessage(EncryptContent: request.EncryptedContent, + ReplyToMessageId: request.ReplyToMessageId, FromUserId: senderId, RecipientId: request.RecipientId, + RecipientType: request.RecipientType, SendAt: DateTime.UtcNow, + Media: request.MediaAttachments?.Select(f => new SendMedia(f.MediaId, f.EncryptedKey)) ?? + Array.Empty()); - try + var result = await _messageService.SendMessageAsync(sendMessageParams); + + if (!result.IsSuccess || result.Message.Id == Guid.Empty) { - var result = await _messageService.SendMessageAsync(sendMessageParams); - - if (!result.IsSuccess || result.Message.Id == Guid.Empty) - { - _logger.LogError(result.Exception, "Failed to send message from {SenderId} to {RecipientId}. Error: {ErrorMessage}", senderId, request.RecipientId, result.Exception?.Message ?? "Unknown error"); - if (result.Exception != null) throw result.Exception; - throw new HubException("Failed to send message due to an internal error."); - } - - var messageResponse = new UserMessageResponse // Assuming a response DTO - { - MessageId = result.Message.Id, - SenderId = result.Message.SenderId, - RecipientId = result.Message.RecipientId, - RecipientType = result.Message.RecipientType, - EncryptedContent = result.Message.EncryptedContent, - SentAt = result.Message.SentAt, - IsEdited = false, - MediaAttachments = result.Message.MediaAttachments - .Select(m => m.MediaFile).ToList(), - ReplyToMessageId = request.ReplyToMessageId - }; - - // Notify recipient (user or group) - if (request.RecipientType == RecipientType.User) - { - // Send to the recipient's personal group - await Clients.Group(request.RecipientId.ToString()).SendAsync("ReceiveMessage", messageResponse); - } - else if (request.RecipientType == RecipientType.Group) - { - // Send to all members of the group, including the sender if they are part of the group via a different connection - await Clients.Group($"group_{request.RecipientId}").SendAsync("ReceiveMessage", messageResponse); - } - - // Notify sender (confirmation) on their connection - await Clients.Caller.SendAsync("MessageSent", messageResponse); // Or use "ReceiveMessage" if the sender should also just get it like anyone else - - _logger.LogInformation("Message {MessageId} sent successfully from {SenderId} to {RecipientId} ({RecipientType})", result.Message.Id, senderId, request.RecipientId, request.RecipientType); + _logger.LogError(result.Exception, + "Failed to send message from {SenderId} to {RecipientId}. Error: {ErrorMessage}", senderId, + request.RecipientId, result.Exception?.Message ?? "Unknown error"); + if (result.Exception != null) + throw result.Exception; + throw new HubException("Failed to send message due to an internal error."); } - catch (Exception ex) + + var messageResponse = new UserMessageResponse // Assuming a response DTO { - _logger.LogError(ex, "Error sending message from {SenderId} to {RecipientId}", senderId, request.RecipientId); - // Consider sending a specific error message to the caller instead of a generic HubException or rethrowing. - // For example: await Clients.Caller.SendAsync("SendMessageFailed", new { Error = ex.Message }); - throw new HubException("An error occurred while sending the message.", ex); + MessageId = result.Message.Id, + SenderId = result.Message.SenderId, + RecipientId = result.Message.RecipientId, + RecipientType = result.Message.RecipientType, + EncryptedContent = result.Message.EncryptedContent, + SentAt = result.Message.SentAt, + IsEdited = false, + MediaAttachments = result.Message.MediaAttachments.Select(m => m.MediaFile).ToList(), + ReplyToMessageId = request.ReplyToMessageId + }; + + // Notify recipient (user or group) + if (request.RecipientType == RecipientType.User) + { + // Send to the recipient's personal group + await Clients.Group(request.RecipientId.ToString()).SendAsync("ReceiveMessage", messageResponse); } + else if (request.RecipientType == RecipientType.Group) + { + // Send to all members of the group, including the sender if they are part of the group via a different connection + await Clients.Group($"group_{request.RecipientId}").SendAsync("ReceiveMessage", messageResponse); + } + + // Notify sender (confirmation) on their connection + await Clients.Caller.SendAsync("MessageSent", + messageResponse); // Or use "ReceiveMessage" if the sender should also just get it like anyone else + + _logger.LogInformation( + "Message {MessageId} sent successfully from {SenderId} to {RecipientId} ({RecipientType})", + result.Message.Id, senderId, request.RecipientId, request.RecipientType); } public async Task Remove(RemoveMessageRequest request) { - } - + public async Task Edit(EditMessageRequest request) { - } - - + private Guid GetUserId(bool suppressException = false) { var userIdClaim = Context.User?.FindFirst("userId")?.Value; @@ -169,8 +169,10 @@ public class ChatsHub : Hub _logger.LogError("Could not retrieve sender userId. Claim was: {UserIDClaim}", userIdClaim); throw new UnauthorizedAccessException("userID claim is missing or invalid."); } + return Guid.Empty; } + return userId; } } \ No newline at end of file diff --git a/Govor.API/Hubs/FriendsHub.cs b/Govor.API/Hubs/FriendsHub.cs index d1a9b30..6fe173a 100644 --- a/Govor.API/Hubs/FriendsHub.cs +++ b/Govor.API/Hubs/FriendsHub.cs @@ -1,38 +1,113 @@ +using Govor.Application.Exceptions.FriendsService; using Govor.Application.Interfaces.Friends; using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.Contracts.Responses.SignalR; using Microsoft.AspNetCore.SignalR; namespace Govor.API.Hubs; public class FriendsHub : Hub { - private readonly IFriendRequestService _friendRequestService; + private readonly ILogger _logger; + private readonly IFriendRequestCommandService _friendRequestService; private readonly ICurrentUserService _currentUserService; - public FriendsHub(IFriendRequestService friendRequestService, ICurrentUserService currentUserService) + public FriendsHub(IFriendRequestCommandService friendRequestService, ICurrentUserService currentUserService, ILogger logger) { _friendRequestService = friendRequestService; _currentUserService = currentUserService; + _logger = logger; } - public async Task SendRequest(Guid targetUserId) + public async Task> SendRequest(Guid targetUserId) { - var userId = _currentUserService.GetCurrentUserId(); - await _friendRequestService.SendFriendRequestAsync(userId, targetUserId); - await Clients.User(targetUserId.ToString()).SendAsync("FriendRequestReceived", userId); + try + { + var userId = _currentUserService.GetCurrentUserId(); + await _friendRequestService.SendAsync(userId, targetUserId); + await Clients.User(targetUserId.ToString()) + .SendAsync("FriendRequestReceived", userId); + + _logger.LogInformation($"Friend request received for user {targetUserId} from {userId}."); + return HubResult.Created(); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, ex.Message); + return HubResult.BadRequest(ex.Message); + } + catch (RequestAlreadySentException ex) + { + _logger.LogWarning(ex, ex.Message); + return HubResult.Conflict(ex.Message); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, ex.Message); + return HubResult.Unauthorized(ex.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + return HubResult.Error("Unexpected error! Please try later!"); + } } - public async Task AcceptRequest(Guid friendshipId) + public async Task> AcceptRequest(Guid friendshipId) { - var userId = _currentUserService.GetCurrentUserId(); - await _friendRequestService.AcceptFriendRequestAsync(friendshipId, userId); - await Clients.User(userId.ToString()).SendAsync("FriendRequestAccepted", friendshipId); + try + { + var userId = _currentUserService.GetCurrentUserId(); + await _friendRequestService.AcceptAsync(friendshipId, userId); + await Clients.User(userId.ToString()) + .SendAsync("FriendRequestAccepted", friendshipId); + + _logger.LogInformation($"Friend request accepted for user {userId} from {userId}."); + return HubResult.Ok(); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, ex.Message); + return HubResult.BadRequest(ex.Message); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, ex.Message); + return HubResult.Unauthorized(ex.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + return HubResult.Error("Unexpected error! Please try later!"); + } } - public async Task RejectRequest(Guid friendshipId) + public async Task> RejectRequest(Guid friendshipId) { - var userId = _currentUserService.GetCurrentUserId(); - await _friendRequestService.RejectFriendRequestAsync(friendshipId, userId); - await Clients.User(userId.ToString()).SendAsync("FriendRequestRejected", friendshipId); + try + { + var userId = _currentUserService.GetCurrentUserId(); + await _friendRequestService.RejectAsync(friendshipId, userId); + await Clients.User(userId.ToString()) + .SendAsync("FriendRequestRejected", friendshipId); + + _logger.LogInformation($"Friend request rejected for user {userId} from {userId}."); + return HubResult.Ok(); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, ex.Message); + return HubResult.BadRequest(ex.Message); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, ex.Message); + return HubResult.Unauthorized(ex.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + return HubResult.Error("Unexpected error! Please try later!"); + } } } \ No newline at end of file diff --git a/Govor.API/Program.cs b/Govor.API/Program.cs index 761a772..18cf04a 100644 --- a/Govor.API/Program.cs +++ b/Govor.API/Program.cs @@ -29,7 +29,7 @@ builder.Services.AddCors(options => builder.Services.Configure(configuration.GetSection(nameof(JwtOption))); // Add services -builder.Services.AddSignalR(); +builder.Services.AddSignalRConf();// signalR builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => diff --git a/Govor.Application/Infrastructure/Validators/UsernameValidator.cs b/Govor.Application/Infrastructure/Validators/UsernameValidator.cs index 2a41e37..6b810c4 100644 --- a/Govor.Application/Infrastructure/Validators/UsernameValidator.cs +++ b/Govor.Application/Infrastructure/Validators/UsernameValidator.cs @@ -8,7 +8,7 @@ namespace Govor.Application.Infrastructure.Validators; public class UsernameValidator : IUsernameValidator { - private readonly Regex _usernameRegex = new(@"^[А-Яа-яЁё]+$", RegexOptions.Compiled); + private readonly Regex _usernameRegex = new(@"^[А-Яа-яЁё]+[А-Яа-яЁё0-9]*$", RegexOptions.Compiled); public void Validate(string username) { diff --git a/Govor.Application/Interfaces/Friends/IFriendRequestCommandService.cs b/Govor.Application/Interfaces/Friends/IFriendRequestCommandService.cs new file mode 100644 index 0000000..21f9d24 --- /dev/null +++ b/Govor.Application/Interfaces/Friends/IFriendRequestCommandService.cs @@ -0,0 +1,8 @@ +namespace Govor.Application.Interfaces.Friends; + +public interface IFriendRequestCommandService +{ + Task SendAsync(Guid fromUserId, Guid toUserId); + Task AcceptAsync(Guid requestId, Guid currentUserId); + Task RejectAsync(Guid requestId, Guid currentUserId); +} diff --git a/Govor.Application/Interfaces/Friends/IFriendRequestQueryService.cs b/Govor.Application/Interfaces/Friends/IFriendRequestQueryService.cs new file mode 100644 index 0000000..93e9525 --- /dev/null +++ b/Govor.Application/Interfaces/Friends/IFriendRequestQueryService.cs @@ -0,0 +1,10 @@ +using Govor.Core.Models; + +namespace Govor.Application.Interfaces.Friends; + + +public interface IFriendRequestQueryService +{ + Task> GetIncomingAsync(Guid userId); + Task> GetResponsesAsync(Guid userId); +} diff --git a/Govor.Application/Interfaces/Friends/IFriendRequestService.cs b/Govor.Application/Interfaces/Friends/IFriendRequestService.cs deleted file mode 100644 index d6d9e37..0000000 --- a/Govor.Application/Interfaces/Friends/IFriendRequestService.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Govor.Core.Models; - -namespace Govor.Application.Interfaces.Friends; - -public interface IFriendRequestService -{ - Task SendFriendRequestAsync(Guid fromUserId, Guid toUserId); - Task AcceptFriendRequestAsync(Guid requestId, Guid currentUserId); - Task RejectFriendRequestAsync(Guid requestId, Guid currentUserId); - Task> GetIncomingRequestsAsync(Guid userId); - Task> GetResponsesAsync(Guid userId); -} \ No newline at end of file diff --git a/Govor.Application/Services/Friends/FriendRequestService.cs b/Govor.Application/Services/Friends/FriendRequestCommandService.cs similarity index 63% rename from Govor.Application/Services/Friends/FriendRequestService.cs rename to Govor.Application/Services/Friends/FriendRequestCommandService.cs index f174074..e4e8780 100644 --- a/Govor.Application/Services/Friends/FriendRequestService.cs +++ b/Govor.Application/Services/Friends/FriendRequestCommandService.cs @@ -6,16 +6,16 @@ using Govor.Data.Repositories.Exceptions; namespace Govor.Application.Services.Friends; -public class FriendRequestService : IFriendRequestService +public class FriendRequestCommandService : IFriendRequestCommandService { private readonly IFriendshipsRepository _friendshipsRepository; - public FriendRequestService(IFriendshipsRepository friendshipsRepository) + public FriendRequestCommandService(IFriendshipsRepository friendshipsRepository) { _friendshipsRepository = friendshipsRepository; } - - public async Task SendFriendRequestAsync(Guid fromUserId, Guid toUserId) + + public async Task SendAsync(Guid fromUserId, Guid toUserId) { if (fromUserId == toUserId) throw new InvalidOperationException("Cannot send a request to self user"); @@ -32,7 +32,7 @@ public class FriendRequestService : IFriendRequestService }); } - public async Task AcceptFriendRequestAsync(Guid requestId, Guid currentUserId) + public async Task AcceptAsync(Guid requestId, Guid currentUserId) { try { @@ -53,7 +53,7 @@ public class FriendRequestService : IFriendRequestService } } - public async Task RejectFriendRequestAsync(Guid requestId, Guid currentUserId) + public async Task RejectAsync(Guid requestId, Guid currentUserId) { try { @@ -73,32 +73,4 @@ public class FriendRequestService : IFriendRequestService throw new InvalidOperationException("Friendship not found! You cant reject request!", ex); } } - - public async Task> GetIncomingRequestsAsync(Guid userId) - { - try - { - var friendships = await _friendshipsRepository.FindByUserIdAsync(userId); - return friendships.Where(f => f.AddresseeId == userId && f.Status == FriendshipStatus.Pending).ToList() - ?? new List(); - } - catch (NotFoundByKeyException ex) - { - throw new InvalidOperationException("User not exist", ex); - } - } - - public async Task> GetResponsesAsync(Guid userId) - { - try - { - var friendships = await _friendshipsRepository.FindByUserIdAsync(userId); - return friendships.Where(f => f.RequesterId == userId && f.Status != FriendshipStatus.Accepted).ToList() - ?? new List(); - } - catch (NotFoundByKeyException ex) - { - throw new InvalidOperationException("User not exist", ex); - } - } } \ No newline at end of file diff --git a/Govor.Application/Services/Friends/FriendRequestQueryService.cs b/Govor.Application/Services/Friends/FriendRequestQueryService.cs new file mode 100644 index 0000000..de66537 --- /dev/null +++ b/Govor.Application/Services/Friends/FriendRequestQueryService.cs @@ -0,0 +1,44 @@ +using Govor.Application.Interfaces.Friends; +using Govor.Core.Models; +using Govor.Core.Repositories.Friendships; +using Govor.Data.Repositories.Exceptions; + +namespace Govor.Application.Services.Friends; + +public class FriendRequestQueryService : IFriendRequestQueryService +{ + private readonly IFriendshipsRepository _friendshipsRepository; + + public FriendRequestQueryService(IFriendshipsRepository friendshipsRepository) + { + _friendshipsRepository = friendshipsRepository; + } + + public async Task> GetIncomingAsync(Guid userId) + { + try + { + var friendships = await _friendshipsRepository.FindByUserIdAsync(userId); + return friendships.Where(f => f.AddresseeId == userId && f.Status == FriendshipStatus.Pending).ToList() + ?? new List(); + } + catch (NotFoundByKeyException ex) + { + throw new InvalidOperationException("User not exist", ex); + } + } + + public async Task> GetResponsesAsync(Guid userId) + { + try + { + var friendships = await _friendshipsRepository.FindByUserIdAsync(userId); + return friendships.Where(f => f.RequesterId == userId && f.Status != FriendshipStatus.Accepted).ToList() + ?? new List(); + } + catch (NotFoundByKeyException ex) + { + throw new InvalidOperationException("User not exist", ex); + } + } +} \ No newline at end of file diff --git a/Govor.Contracts/Responses/SignalR/HubResult.cs b/Govor.Contracts/Responses/SignalR/HubResult.cs new file mode 100644 index 0000000..a75ca80 --- /dev/null +++ b/Govor.Contracts/Responses/SignalR/HubResult.cs @@ -0,0 +1,76 @@ +namespace Govor.Contracts.Responses.SignalR; + +public class HubResult +{ + public HubResultStatus Status { get; set; } + public T? Result { get; set; } + public string? ErrorMessage { get; set; } + + public static HubResult Ok(T? result = default) => new() + { + Status = HubResultStatus.Success, + Result = result + }; + + public static HubResult Created(T? result = default) => new() + { + Status = HubResultStatus.Created, + Result = result + }; + + public static HubResult NoContent() => new() + { + Status = HubResultStatus.NoContent + }; + + public static HubResult BadRequest(string message, T? details = default) => new() + { + Status = HubResultStatus.BadRequest, + ErrorMessage = message, + Result = details + }; + + public static HubResult NotFound(string message, T? details = default) => new() + { + Status = HubResultStatus.NotFound, + ErrorMessage = message, + Result = details + }; + + public static HubResult Unauthorized(string message) => new() + { + Status = HubResultStatus.Unauthorized, + ErrorMessage = message + }; + + public static HubResult Conflict(string message) => new() + { + Status = HubResultStatus.Conflict, + ErrorMessage = message, + }; + + public static HubResult UnprocessableEntity(string message) => new() + { + Status = HubResultStatus.UnprocessableEntity, + ErrorMessage = message + }; + + public static HubResult Error(string message) => new() + { + Status = HubResultStatus.ServerError, + ErrorMessage = message + }; +} + +public enum HubResultStatus +{ + Success = 200, + Created = 201, + NoContent = 204, + BadRequest = 400, + Unauthorized = 401, + NotFound = 404, + Conflict = 409, + UnprocessableEntity = 422, + ServerError = 500, +} \ No newline at end of file From a43a26b3076786c72f318e1713ee0ba8ae9af105 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Thu, 10 Jul 2025 16:12:32 +0700 Subject: [PATCH 038/101] FriendsHubTests --- .../IntegrationTests/Hubs/FriendsHubTests.cs | 270 ++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 Govor.API.Tests/IntegrationTests/Hubs/FriendsHubTests.cs diff --git a/Govor.API.Tests/IntegrationTests/Hubs/FriendsHubTests.cs b/Govor.API.Tests/IntegrationTests/Hubs/FriendsHubTests.cs new file mode 100644 index 0000000..9f88695 --- /dev/null +++ b/Govor.API.Tests/IntegrationTests/Hubs/FriendsHubTests.cs @@ -0,0 +1,270 @@ +using Govor.API.Hubs; +using Govor.Application.Exceptions.FriendsService; +using Govor.Application.Interfaces.Friends; +using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.Contracts.Responses.SignalR; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Govor.API.Tests.IntegrationTests.Hubs; + +[TestFixture] +public class FriendsHubTests +{ + private Mock _friendRequestServiceMock = null!; + private Mock _currentUserServiceMock = null!; + private Mock _clientsMock = null!; + private Mock _clientProxyMock = null!; + private Mock> _loggerMock = null!; + private FriendsHub _hub = null!; + private readonly Guid _userId = Guid.NewGuid(); + private readonly Guid _targetUserId = Guid.NewGuid(); + + [SetUp] + public void Setup() + { + _friendRequestServiceMock = new Mock(); + _currentUserServiceMock = new Mock(); + _clientsMock = new Mock(); + _clientProxyMock = new Mock(); + _loggerMock = new Mock>(); + + _currentUserServiceMock.Setup(x => x.GetCurrentUserId()).Returns(_userId); + _clientsMock.Setup(c => c.User(It.IsAny())).Returns(_clientProxyMock.Object); + + _hub = new FriendsHub( + _friendRequestServiceMock.Object, + _currentUserServiceMock.Object, + _loggerMock.Object) + { + Clients = _clientsMock.Object + }; + } + + // Tests for SendRequest action + [Test] + public async Task SendRequest_ShouldReturnCreated_WhenSuccess() + { + // Act + var result = await _hub.SendRequest(_targetUserId); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.Status, Is.EqualTo(HubResultStatus.Created)); + + _friendRequestServiceMock.Verify(s => s.SendAsync(_userId, _targetUserId), Times.Once); + + _clientProxyMock.Verify(c => c.SendCoreAsync( + "FriendRequestReceived", + It.Is(o => o[0]!.Equals(_userId)), + default), Times.Once); + } + + [Test] + public async Task SendRequest_ShouldReturnBadRequest_WhenInvalidOperation() + { + // Arrange + _friendRequestServiceMock + .Setup(s => s.SendAsync(_userId, _targetUserId)) + .ThrowsAsync(new InvalidOperationException("Invalid")); + + // Act + var result = await _hub.SendRequest(_targetUserId); + + // Assert + Assert.That(result.Status, Is.EqualTo(HubResultStatus.BadRequest)); + Assert.That(result.ErrorMessage, Is.EqualTo("Invalid")); + } + + [Test] + public async Task SendRequest_ShouldReturnConflict_WhenAlreadySent() + { + // Arrange + _friendRequestServiceMock + .Setup(s => s.SendAsync(_userId, _targetUserId)) + .ThrowsAsync(new RequestAlreadySentException(_userId, _targetUserId)); + + // Act + var result = await _hub.SendRequest(_targetUserId); + + // Assert + Assert.That(result.Status, Is.EqualTo(HubResultStatus.Conflict)); + Assert.That(result.ErrorMessage, Is.EqualTo($"Request was already sent from {_userId} to {_targetUserId}")); + } + + [Test] + public async Task SendRequest_ShouldReturnUnauthorized_WhenCurrentUserIsNotAuthenticated() + { + // Arrange + _currentUserServiceMock.Setup(x => x.GetCurrentUserId()) + .Throws(new UnauthorizedAccessException("userId claim is missing or invalid")); + + // Act + var result = await _hub.SendRequest(_targetUserId); + + // Assert + Assert.That(result.Status, Is.EqualTo(HubResultStatus.Unauthorized)); + Assert.That(result.ErrorMessage, Is.EqualTo("userId claim is missing or invalid")); + } + + [Test] + public async Task SendRequest_ShouldReturnError_WhenSomeExceptionOccurs() + { + // Arrange + _friendRequestServiceMock + .Setup(s => s.SendAsync(_userId, _targetUserId)) + .ThrowsAsync(new Exception("error")); + + // Act + var result = await _hub.SendRequest(_targetUserId); + + // Assert + Assert.That(result.Status, Is.EqualTo(HubResultStatus.ServerError)); + Assert.That(result.ErrorMessage, Is.EqualTo("Unexpected error! Please try later!")); + } + + // Test for AcceptRequest action + [Test] + public async Task AcceptRequest_ShouldReturnOk_WhenSuccess() + { + // Arrange + var friendshipId = Guid.NewGuid(); + + // Act + var result = await _hub.AcceptRequest(friendshipId); + + // Assert + Assert.That(result.Status, Is.EqualTo(HubResultStatus.Success)); + _friendRequestServiceMock.Verify(s => s.AcceptAsync(friendshipId, _userId), Times.Once); + + _clientProxyMock.Verify(c => c.SendCoreAsync( + "FriendRequestAccepted", + It.Is(o => o[0]!.Equals(friendshipId)), + default), Times.Once); + } + + [Test] + public async Task AcceptRequest_ShouldReturnBadRequest_InvalidOperation() + { + // Arrange + var friendshipId = Guid.NewGuid(); + _friendRequestServiceMock + .Setup(s => s.AcceptAsync(friendshipId, _userId)) + .ThrowsAsync(new InvalidOperationException("Invalid")); + + // Act + var result = await _hub.AcceptRequest(friendshipId); + + // Assert + Assert.That(result.Status, Is.EqualTo(HubResultStatus.BadRequest)); + Assert.That(result.ErrorMessage, Is.EqualTo("Invalid")); + } + + + [Test] + public async Task AcceptRequest_ShouldReturnUnauthorized_UnauthorizedAccess() + { + // Arrange + var friendshipId = Guid.NewGuid(); + _friendRequestServiceMock + .Setup(s => s.AcceptAsync(friendshipId, _userId)) + .ThrowsAsync(new UnauthorizedAccessException("userId claim is missing or invalid")); + + // Act + var result = await _hub.AcceptRequest(friendshipId); + + // Assert + Assert.That(result.Status, Is.EqualTo(HubResultStatus.Unauthorized)); + Assert.That(result.ErrorMessage, Is.EqualTo("userId claim is missing or invalid")); + } + + [Test] + public async Task AcceptRequest_ShouldReturnError_WhenSomeExceptionOccurs() + { + // Arrange + var friendshipId = Guid.NewGuid(); + _friendRequestServiceMock + .Setup(s => s.AcceptAsync(friendshipId, _userId)) + .ThrowsAsync(new Exception("error")); + + // Act + var result = await _hub.AcceptRequest(friendshipId); + + // Assert + Assert.That(result.Status, Is.EqualTo(HubResultStatus.ServerError)); + Assert.That(result.ErrorMessage, Is.EqualTo("Unexpected error! Please try later!")); + } + + // Test for RejectRequest action + [Test] + public async Task RejectRequest_ShouldReturnOk_WhenSuccess() + { + // Arrange + var friendshipId = Guid.NewGuid(); + + // Act + var result = await _hub.RejectRequest(friendshipId); + + // Assert + Assert.That(result.Status, Is.EqualTo(HubResultStatus.Success)); + _friendRequestServiceMock.Verify(s => s.RejectAsync(friendshipId, _userId), Times.Once); + + _clientProxyMock.Verify(c => c.SendCoreAsync( + "FriendRequestRejected", + It.Is(o => o[0]!.Equals(friendshipId)), + default), Times.Once); + } + + [Test] + public async Task RejectRequest_ShouldReturnBadRequest_InvalidOperation() + { + // Arrange + var friendshipId = Guid.NewGuid(); + _friendRequestServiceMock + .Setup(s => s.RejectAsync(friendshipId, _userId)) + .ThrowsAsync(new InvalidOperationException("Invalid")); + + // Act + var result = await _hub.RejectRequest(friendshipId); + + // Assert + Assert.That(result.Status, Is.EqualTo(HubResultStatus.BadRequest)); + Assert.That(result.ErrorMessage, Is.EqualTo("Invalid")); + } + + + [Test] + public async Task RejectRequest_ShouldReturnUnauthorized_UnauthorizedAccess() + { + // Arrange + var friendshipId = Guid.NewGuid(); + _friendRequestServiceMock + .Setup(s => s.RejectAsync(friendshipId, _userId)) + .ThrowsAsync(new UnauthorizedAccessException("userId claim is missing or invalid")); + + // Act + var result = await _hub.RejectRequest(friendshipId); + + // Assert + Assert.That(result.Status, Is.EqualTo(HubResultStatus.Unauthorized)); + Assert.That(result.ErrorMessage, Is.EqualTo("userId claim is missing or invalid")); + } + + [Test] + public async Task RejectRequest_ShouldReturnError_WhenSomeExceptionOccurs() + { + // Arrange + var friendshipId = Guid.NewGuid(); + _friendRequestServiceMock + .Setup(s => s.RejectAsync(friendshipId, _userId)) + .ThrowsAsync(new Exception("error")); + + // Act + var result = await _hub.RejectRequest(friendshipId); + + // Assert + Assert.That(result.Status, Is.EqualTo(HubResultStatus.ServerError)); + Assert.That(result.ErrorMessage, Is.EqualTo("Unexpected error! Please try later!")); + } +} From 65a43c09d342e06291292036640e2a1ff1a7ba0d Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Thu, 10 Jul 2025 18:05:10 +0700 Subject: [PATCH 039/101] Refactor message service and move tests to Application.Tests Renamed IMessageService to IMessageCommandService and updated all usages accordingly. Moved and renamed test files from Govor.API.Tests to the new Govor.Application.Tests project, updating namespaces to match. Refactored message-related services and controllers to use the new naming and structure. Added Govor.Application.Tests project to the solution. --- Govor.API.Tests/Govor.API.Tests.csproj | 1 - .../IntegrationTests/Hubs/ChatsHubTests.cs | 4 +- .../Services => }/LocalStorageServiceTests.cs | 2 +- .../Friends/FriendsRequestQueryController.cs | 15 +++++ .../Friends/FriendshipController.cs | 55 ++++++++++++++++++- .../ConfigurationProgramExtensions.cs | 5 +- Govor.API/Hubs/ChatsHub.cs | 8 +-- Govor.API/Program.cs | 4 +- .../Govor.Application.Tests.csproj | 33 +++++++++++ .../AdminsStuff}/InvitationGeneratorTests.cs | 4 +- .../Extensions}/CurrentUserServiceTests.cs | 2 +- .../Authentication/AuthServiceTests.cs | 3 +- .../Authentication/JwtServiceTests.cs | 3 +- .../FriendRequestCommandServiceTests.cs | 2 +- .../Friends/FriendRequestQueryServiceTests.cs | 2 +- .../Friends/FriendshipServiceTests.cs | 2 +- .../Messages/MessageCommandServiceTests.cs | 14 ++--- .../Services/PasswordHasherTests.cs | 4 +- .../Services/PingHandlerServiceTests.cs | 2 +- .../Services/UserGroupsServiceTests.cs | 2 +- .../Services/VerifyFriendshipTests.cs | 2 +- .../Validators/UsernameValidatorTests.cs | 0 .../AdminsStuff/InvitationGenerator.cs | 2 +- .../AdminsStuff/UsersService.cs | 0 ...geService.cs => IMessageCommandService.cs} | 2 +- .../{ => Authentication}/AuthService.cs | 2 +- .../{ => Authentication}/JwtOption.cs | 2 +- .../{ => Authentication}/JwtService.cs | 2 +- .../{ => Authentication}/PasswordHasher.cs | 2 +- .../Services/{ => Messages}/MediaService.cs | 2 +- .../MessageCommandService.cs} | 10 ++-- .../Services/{ => Messages}/MessagesLoader.cs | 2 +- Govor.sln | 7 +++ 33 files changed, 157 insertions(+), 45 deletions(-) rename Govor.API.Tests/{UnitTests/Services => }/LocalStorageServiceTests.cs (98%) create mode 100644 Govor.Application.Tests/Govor.Application.Tests.csproj rename {Govor.API.Tests/UnitTests/Services/AdminStuff => Govor.Application.Tests/Infrastructure/AdminsStuff}/InvitationGeneratorTests.cs (96%) rename {Govor.API.Tests/UnitTests/Services => Govor.Application.Tests/Infrastructure/Extensions}/CurrentUserServiceTests.cs (98%) rename {Govor.API.Tests/UnitTests => Govor.Application.Tests}/Services/Authentication/AuthServiceTests.cs (97%) rename {Govor.API.Tests/UnitTests => Govor.Application.Tests}/Services/Authentication/JwtServiceTests.cs (94%) rename {Govor.API.Tests/UnitTests => Govor.Application.Tests}/Services/Friends/FriendRequestCommandServiceTests.cs (99%) rename {Govor.API.Tests/UnitTests => Govor.Application.Tests}/Services/Friends/FriendRequestQueryServiceTests.cs (98%) rename {Govor.API.Tests/UnitTests => Govor.Application.Tests}/Services/Friends/FriendshipServiceTests.cs (99%) rename Govor.API.Tests/UnitTests/Services/MessageServiceTests.cs => Govor.Application.Tests/Services/Messages/MessageCommandServiceTests.cs (96%) rename {Govor.API.Tests/UnitTests => Govor.Application.Tests}/Services/PasswordHasherTests.cs (92%) rename {Govor.API.Tests/UnitTests => Govor.Application.Tests}/Services/PingHandlerServiceTests.cs (97%) rename {Govor.API.Tests/UnitTests => Govor.Application.Tests}/Services/UserGroupsServiceTests.cs (97%) rename {Govor.API.Tests/UnitTests => Govor.Application.Tests}/Services/VerifyFriendshipTests.cs (98%) rename {Govor.API.Tests/UnitTests/Services => Govor.Application.Tests}/Validators/UsernameValidatorTests.cs (100%) rename Govor.Application/{Interfaces => Infrastructure}/AdminsStuff/InvitationGenerator.cs (93%) rename Govor.Application/{Interfaces => Infrastructure}/AdminsStuff/UsersService.cs (100%) rename Govor.Application/Interfaces/Messages/{IMessageService.cs => IMessageCommandService.cs} (97%) rename Govor.Application/Services/{ => Authentication}/AuthService.cs (98%) rename Govor.Application/Services/{ => Authentication}/JwtOption.cs (65%) rename Govor.Application/Services/{ => Authentication}/JwtService.cs (95%) rename Govor.Application/Services/{ => Authentication}/PasswordHasher.cs (87%) rename Govor.Application/Services/{ => Messages}/MediaService.cs (92%) rename Govor.Application/Services/{MessageService.cs => Messages/MessageCommandService.cs} (97%) rename Govor.Application/Services/{ => Messages}/MessagesLoader.cs (96%) diff --git a/Govor.API.Tests/Govor.API.Tests.csproj b/Govor.API.Tests/Govor.API.Tests.csproj index 541926b..2ef9646 100644 --- a/Govor.API.Tests/Govor.API.Tests.csproj +++ b/Govor.API.Tests/Govor.API.Tests.csproj @@ -27,5 +27,4 @@ - diff --git a/Govor.API.Tests/IntegrationTests/Hubs/ChatsHubTests.cs b/Govor.API.Tests/IntegrationTests/Hubs/ChatsHubTests.cs index 01d0682..8b4264c 100644 --- a/Govor.API.Tests/IntegrationTests/Hubs/ChatsHubTests.cs +++ b/Govor.API.Tests/IntegrationTests/Hubs/ChatsHubTests.cs @@ -11,7 +11,7 @@ namespace Govor.API.Tests.IntegrationTests.Hubs; public class ChatsHubTests { private Mock> _loggerMock; - private Mock _messageServiceMock; + private Mock _messageServiceMock; private Mock _userGroupsServiceMock; private Fixture _fixture; private ChatsHub _chatsHub; @@ -23,7 +23,7 @@ public class ChatsHubTests _fixture.Behaviors.OfType().ToList().ForEach(b => _fixture.Behaviors.Remove(b)); _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); - _messageServiceMock = new Mock(); + _messageServiceMock = new Mock(); _userGroupsServiceMock = new Mock(); _loggerMock = new Mock>(); diff --git a/Govor.API.Tests/UnitTests/Services/LocalStorageServiceTests.cs b/Govor.API.Tests/LocalStorageServiceTests.cs similarity index 98% rename from Govor.API.Tests/UnitTests/Services/LocalStorageServiceTests.cs rename to Govor.API.Tests/LocalStorageServiceTests.cs index c80da0a..aff7ac8 100644 --- a/Govor.API.Tests/UnitTests/Services/LocalStorageServiceTests.cs +++ b/Govor.API.Tests/LocalStorageServiceTests.cs @@ -4,7 +4,7 @@ using Govor.Application.Services; using Microsoft.AspNetCore.Hosting; using Moq; -namespace Govor.API.Tests.UnitTests.Services; +namespace Govor.Application.Tests.Services; [TestFixture] public class LocalStorageServiceTests diff --git a/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs b/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs index 0863894..d01c507 100644 --- a/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs +++ b/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs @@ -1,3 +1,5 @@ +using Govor.Application.Interfaces.Friends; +using Govor.Application.Interfaces.Infrastructure.Extensions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -8,5 +10,18 @@ namespace Govor.API.Controllers.Friends; [Route("api/friends")] public class FriendsRequestQueryController : Controller { + private readonly ILogger _logger; + private readonly IFriendRequestQueryService _friendsService; + private readonly ICurrentUserService _currentUserService; + + public FriendsRequestQueryController(ILogger logger, + IFriendRequestQueryService friendsService, + ICurrentUserService currentUserService) + { + _logger = logger; + _friendsService = friendsService; + _currentUserService = currentUserService; + } + } \ No newline at end of file diff --git a/Govor.API/Controllers/Friends/FriendshipController.cs b/Govor.API/Controllers/Friends/FriendshipController.cs index fbfe156..8daf319 100644 --- a/Govor.API/Controllers/Friends/FriendshipController.cs +++ b/Govor.API/Controllers/Friends/FriendshipController.cs @@ -1,6 +1,59 @@ +using Govor.Application.Exceptions.FriendsService; +using Govor.Application.Interfaces.Friends; +using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.Contracts.DTOs; +using Govor.Core.Models; +using Microsoft.AspNetCore.Mvc; + namespace Govor.API.Controllers.Friends; -public class FriendshipController +[Route("api/friends")] +public class FriendshipController : Controller { + private readonly ILogger _logger; + private readonly IFriendshipService _friendsService; + //private readonly IUserDtoBuilder _builder; + private readonly ICurrentUserService _currentUserService; + + public FriendshipController(ILogger logger, + IFriendshipService friendsService, + ICurrentUserService currentUserService) + { + _logger = logger; + _friendsService = friendsService; + _currentUserService = currentUserService; + } + [HttpGet("search")] // api/friends/search?query= + public async Task Search(string query) + { + if (string.IsNullOrWhiteSpace(query)) + return BadRequest("Query cannot be empty"); + + try + { + var result = await _friendsService.SearchUsersAsync(query, _currentUserService.GetCurrentUserId()); + return Ok(BuildUserDtos(result)); + } + catch (SearchUsersException ex) + { + _logger.LogWarning(ex, ex.Message); + return NotFound(new { error = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + return StatusCode(500, new { error = "Internal error during user search." }); + } + } + + private List BuildUserDtos(IEnumerable users) => users.Select(user => new UserDto + { + Id = user.Id, + Username = user.Username, + Description = user.Description, + WasOnline = user.WasOnline, + IconId = user.IconId + }).ToList(); + } \ No newline at end of file diff --git a/Govor.API/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Extensions/ConfigurationProgramExtensions.cs index 88ec6d8..f85954e 100644 --- a/Govor.API/Extensions/ConfigurationProgramExtensions.cs +++ b/Govor.API/Extensions/ConfigurationProgramExtensions.cs @@ -1,5 +1,6 @@ using Govor.API.Services.AdminsStuff.Interfaces; using Govor.API.Services.Authentication.Interfaces; +using Govor.Application.Infrastructure.AdminsStuff; using Govor.Application.Infrastructure.Extensions; using Govor.Application.Infrastructure.Validators; using Govor.Application.Interfaces; @@ -9,7 +10,9 @@ using Govor.Application.Interfaces.Friends; using Govor.Application.Interfaces.Infrastructure.Extensions; using Govor.Application.Interfaces.Messages; using Govor.Application.Services; +using Govor.Application.Services.Authentication; using Govor.Application.Services.Friends; +using Govor.Application.Services.Messages; using Govor.Core.Infrastructure.Extensions; using Govor.Core.Infrastructure.Validators; using Govor.Core.Models; @@ -57,7 +60,7 @@ public static class ConfigurationProgramExtensions services.AddMemoryCache(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/Govor.API/Hubs/ChatsHub.cs b/Govor.API/Hubs/ChatsHub.cs index f8e380b..f0f509c 100644 --- a/Govor.API/Hubs/ChatsHub.cs +++ b/Govor.API/Hubs/ChatsHub.cs @@ -14,13 +14,13 @@ namespace Govor.API.Hubs; public class ChatsHub : Hub { private readonly ILogger _logger; - private readonly IMessageService _messageService; + private readonly IMessageCommandService _messageCommandService; private readonly IUserGroupsService _userService; - public ChatsHub(ILogger logger, IMessageService messageService, IUserGroupsService userService) + public ChatsHub(ILogger logger, IMessageCommandService messageCommandService, IUserGroupsService userService) { _logger = logger; - _messageService = messageService; + _messageCommandService = messageCommandService; _userService = userService; } @@ -105,7 +105,7 @@ public class ChatsHub : Hub Media: request.MediaAttachments?.Select(f => new SendMedia(f.MediaId, f.EncryptedKey)) ?? Array.Empty()); - var result = await _messageService.SendMessageAsync(sendMessageParams); + var result = await _messageCommandService.SendMessageAsync(sendMessageParams); if (!result.IsSuccess || result.Message.Id == Guid.Empty) { diff --git a/Govor.API/Program.cs b/Govor.API/Program.cs index 18cf04a..fd3e0ac 100644 --- a/Govor.API/Program.cs +++ b/Govor.API/Program.cs @@ -1,7 +1,7 @@ using System.Text; using Govor.API.Extensions; using Govor.API.Hubs; -using Govor.Application.Services; +using Govor.Application.Services.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; @@ -19,7 +19,7 @@ builder.Services.AddCors(options => { options.AddPolicy("AllowFrontend", policy => { - policy.WithOrigins("http://localhost:5000", "https://5.129.212.144:5000") // Укажите ваш публичный IP + policy.WithOrigins("http://localhost:5000", "https://5.129.212.144:5000") .AllowAnyHeader() .AllowAnyMethod() .AllowCredentials(); diff --git a/Govor.Application.Tests/Govor.Application.Tests.csproj b/Govor.Application.Tests/Govor.Application.Tests.csproj new file mode 100644 index 0000000..cdd654f --- /dev/null +++ b/Govor.Application.Tests/Govor.Application.Tests.csproj @@ -0,0 +1,33 @@ + + + + net8.0 + latest + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Govor.API.Tests/UnitTests/Services/AdminStuff/InvitationGeneratorTests.cs b/Govor.Application.Tests/Infrastructure/AdminsStuff/InvitationGeneratorTests.cs similarity index 96% rename from Govor.API.Tests/UnitTests/Services/AdminStuff/InvitationGeneratorTests.cs rename to Govor.Application.Tests/Infrastructure/AdminsStuff/InvitationGeneratorTests.cs index 98c50a1..4ad9d52 100644 --- a/Govor.API.Tests/UnitTests/Services/AdminStuff/InvitationGeneratorTests.cs +++ b/Govor.Application.Tests/Infrastructure/AdminsStuff/InvitationGeneratorTests.cs @@ -1,11 +1,11 @@ using AutoFixture; using Govor.API.Services.AdminsStuff.Interfaces; -using Govor.Application.Interfaces.AdminsStuff; +using Govor.Application.Infrastructure.AdminsStuff; using Govor.Core.Models; using Govor.Core.Repositories.Invaites; using Moq; -namespace Govor.API.Tests.UnitTests.Services.AdminStuff; +namespace Govor.Application.Tests.Infrastructure.AdminsStuff; [TestFixture] public class InvitationGeneratorTests diff --git a/Govor.API.Tests/UnitTests/Services/CurrentUserServiceTests.cs b/Govor.Application.Tests/Infrastructure/Extensions/CurrentUserServiceTests.cs similarity index 98% rename from Govor.API.Tests/UnitTests/Services/CurrentUserServiceTests.cs rename to Govor.Application.Tests/Infrastructure/Extensions/CurrentUserServiceTests.cs index 352339e..69b8fbc 100644 --- a/Govor.API.Tests/UnitTests/Services/CurrentUserServiceTests.cs +++ b/Govor.Application.Tests/Infrastructure/Extensions/CurrentUserServiceTests.cs @@ -3,7 +3,7 @@ using Govor.Application.Infrastructure.Extensions; using Microsoft.AspNetCore.Http; using Moq; -namespace Govor.API.Tests.UnitTests.Services; +namespace Govor.Application.Tests.Infrastructure.Extensions; [TestFixture] public class CurrentUserServiceTests diff --git a/Govor.API.Tests/UnitTests/Services/Authentication/AuthServiceTests.cs b/Govor.Application.Tests/Services/Authentication/AuthServiceTests.cs similarity index 97% rename from Govor.API.Tests/UnitTests/Services/Authentication/AuthServiceTests.cs rename to Govor.Application.Tests/Services/Authentication/AuthServiceTests.cs index 3abea94..389be9a 100644 --- a/Govor.API.Tests/UnitTests/Services/Authentication/AuthServiceTests.cs +++ b/Govor.Application.Tests/Services/Authentication/AuthServiceTests.cs @@ -6,10 +6,11 @@ using Govor.API.Services.Authentication.Interfaces; using Govor.Application.Exceptions.AuthService; using Govor.Application.Interfaces.Authentication; using Govor.Application.Services; +using Govor.Application.Services.Authentication; using Govor.Core.Repositories.Admins; using Moq; -namespace Govor.API.Tests.UnitTests.Services.Authentication; +namespace Govor.Application.Tests.Services.Authentication; [TestFixture] public class AuthServiceTests diff --git a/Govor.API.Tests/UnitTests/Services/Authentication/JwtServiceTests.cs b/Govor.Application.Tests/Services/Authentication/JwtServiceTests.cs similarity index 94% rename from Govor.API.Tests/UnitTests/Services/Authentication/JwtServiceTests.cs rename to Govor.Application.Tests/Services/Authentication/JwtServiceTests.cs index 9e4acb8..3fa1585 100644 --- a/Govor.API.Tests/UnitTests/Services/Authentication/JwtServiceTests.cs +++ b/Govor.Application.Tests/Services/Authentication/JwtServiceTests.cs @@ -2,11 +2,12 @@ using System.IdentityModel.Tokens.Jwt; using AutoFixture; using Govor.API.Services.Authentication.Interfaces; using Govor.Application.Services; +using Govor.Application.Services.Authentication; using Govor.Core.Models; using Microsoft.Extensions.Options; using Moq; -namespace Govor.API.Tests.UnitTests.Services.Authentication; +namespace Govor.Application.Tests.Services.Authentication; [TestFixture] public class JwtServiceTests diff --git a/Govor.API.Tests/UnitTests/Services/Friends/FriendRequestCommandServiceTests.cs b/Govor.Application.Tests/Services/Friends/FriendRequestCommandServiceTests.cs similarity index 99% rename from Govor.API.Tests/UnitTests/Services/Friends/FriendRequestCommandServiceTests.cs rename to Govor.Application.Tests/Services/Friends/FriendRequestCommandServiceTests.cs index 2b2baa3..2cfd781 100644 --- a/Govor.API.Tests/UnitTests/Services/Friends/FriendRequestCommandServiceTests.cs +++ b/Govor.Application.Tests/Services/Friends/FriendRequestCommandServiceTests.cs @@ -8,7 +8,7 @@ using Govor.Core.Repositories.Users; using Govor.Data.Repositories.Exceptions; using Moq; -namespace Govor.API.Tests.UnitTests.Services.Friends; +namespace Govor.Application.Tests.Services.Friends; [TestFixture] public class FriendRequestCommandServiceTests diff --git a/Govor.API.Tests/UnitTests/Services/Friends/FriendRequestQueryServiceTests.cs b/Govor.Application.Tests/Services/Friends/FriendRequestQueryServiceTests.cs similarity index 98% rename from Govor.API.Tests/UnitTests/Services/Friends/FriendRequestQueryServiceTests.cs rename to Govor.Application.Tests/Services/Friends/FriendRequestQueryServiceTests.cs index 7870a5b..3cade62 100644 --- a/Govor.API.Tests/UnitTests/Services/Friends/FriendRequestQueryServiceTests.cs +++ b/Govor.Application.Tests/Services/Friends/FriendRequestQueryServiceTests.cs @@ -7,7 +7,7 @@ using Govor.Core.Repositories.Users; using Govor.Data.Repositories.Exceptions; using Moq; -namespace Govor.API.Tests.UnitTests.Services.Friends; +namespace Govor.Application.Tests.Services.Friends; [TestFixture] public class FriendRequestQueryServiceTests diff --git a/Govor.API.Tests/UnitTests/Services/Friends/FriendshipServiceTests.cs b/Govor.Application.Tests/Services/Friends/FriendshipServiceTests.cs similarity index 99% rename from Govor.API.Tests/UnitTests/Services/Friends/FriendshipServiceTests.cs rename to Govor.Application.Tests/Services/Friends/FriendshipServiceTests.cs index abb9205..6a11e56 100644 --- a/Govor.API.Tests/UnitTests/Services/Friends/FriendshipServiceTests.cs +++ b/Govor.Application.Tests/Services/Friends/FriendshipServiceTests.cs @@ -8,7 +8,7 @@ using Govor.Core.Repositories.Users; using Govor.Data.Repositories.Exceptions; using Moq; -namespace Govor.API.Tests.UnitTests.Services.Friends; +namespace Govor.Application.Tests.Services.Friends; [TestFixture] public class FriendshipServiceTests diff --git a/Govor.API.Tests/UnitTests/Services/MessageServiceTests.cs b/Govor.Application.Tests/Services/Messages/MessageCommandServiceTests.cs similarity index 96% rename from Govor.API.Tests/UnitTests/Services/MessageServiceTests.cs rename to Govor.Application.Tests/Services/Messages/MessageCommandServiceTests.cs index d415335..f5abc7b 100644 --- a/Govor.API.Tests/UnitTests/Services/MessageServiceTests.cs +++ b/Govor.Application.Tests/Services/Messages/MessageCommandServiceTests.cs @@ -1,7 +1,7 @@ using Govor.Application.Exceptions.VerifyFriendship; using Govor.Application.Interfaces; using Govor.Application.Interfaces.Messages.Parameters; -using Govor.Application.Services; +using Govor.Application.Services.Messages; using Govor.Core.Models; using Govor.Core.Repositories.Groups; using Govor.Core.Repositories.Messages; @@ -11,18 +11,18 @@ using Govor.Data.Repositories.Exceptions; using Microsoft.Extensions.Logging; using Moq; -namespace Govor.API.Tests.UnitTests.Services; +namespace Govor.Application.Tests.Services.Messages; [TestFixture] -public class MessageServiceTests +public class MessageCommandServiceTests { private Mock _mockMessagesRepo; private Mock _mockUsersRepo; private Mock _mockGroupsRepo; private Mock _mockVerifyFriendship; private Mock _mockPrivateChats; - private Mock> _mockLogger; - private MessageService _messageService; + private Mock> _mockLogger; + private MessageCommandService _messageService; [SetUp] public void SetUp() @@ -32,9 +32,9 @@ public class MessageServiceTests _mockGroupsRepo = new Mock(); _mockVerifyFriendship = new Mock(); _mockPrivateChats = new Mock(); - _mockLogger = new Mock>(); + _mockLogger = new Mock>(); - _messageService = new MessageService( + _messageService = new MessageCommandService( _mockMessagesRepo.Object, _mockUsersRepo.Object, _mockGroupsRepo.Object, diff --git a/Govor.API.Tests/UnitTests/Services/PasswordHasherTests.cs b/Govor.Application.Tests/Services/PasswordHasherTests.cs similarity index 92% rename from Govor.API.Tests/UnitTests/Services/PasswordHasherTests.cs rename to Govor.Application.Tests/Services/PasswordHasherTests.cs index d060db5..a0e4074 100644 --- a/Govor.API.Tests/UnitTests/Services/PasswordHasherTests.cs +++ b/Govor.Application.Tests/Services/PasswordHasherTests.cs @@ -1,8 +1,8 @@ using AutoFixture; -using Govor.Application.Services; +using Govor.Application.Services.Authentication; using Govor.Core.Infrastructure.Extensions; -namespace Govor.API.Tests.UnitTests.Services; +namespace Govor.Application.Tests.Services; [TestFixture] public class PasswordHasherTests diff --git a/Govor.API.Tests/UnitTests/Services/PingHandlerServiceTests.cs b/Govor.Application.Tests/Services/PingHandlerServiceTests.cs similarity index 97% rename from Govor.API.Tests/UnitTests/Services/PingHandlerServiceTests.cs rename to Govor.Application.Tests/Services/PingHandlerServiceTests.cs index 312342b..44ba86b 100644 --- a/Govor.API.Tests/UnitTests/Services/PingHandlerServiceTests.cs +++ b/Govor.Application.Tests/Services/PingHandlerServiceTests.cs @@ -4,7 +4,7 @@ using Govor.Data; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; -namespace Govor.API.Tests.UnitTests.Services; +namespace Govor.Application.Tests.Services; [TestFixture] public class PingHandlerServiceTests diff --git a/Govor.API.Tests/UnitTests/Services/UserGroupsServiceTests.cs b/Govor.Application.Tests/Services/UserGroupsServiceTests.cs similarity index 97% rename from Govor.API.Tests/UnitTests/Services/UserGroupsServiceTests.cs rename to Govor.Application.Tests/Services/UserGroupsServiceTests.cs index 4849ed8..d093df6 100644 --- a/Govor.API.Tests/UnitTests/Services/UserGroupsServiceTests.cs +++ b/Govor.Application.Tests/Services/UserGroupsServiceTests.cs @@ -6,7 +6,7 @@ using Govor.Core.Repositories.Groups; using Govor.Data.Repositories.Exceptions; using Moq; -namespace Govor.API.Tests.UnitTests.Services; +namespace Govor.Application.Tests.Services; [TestFixture] public class UserGroupsServiceTests diff --git a/Govor.API.Tests/UnitTests/Services/VerifyFriendshipTests.cs b/Govor.Application.Tests/Services/VerifyFriendshipTests.cs similarity index 98% rename from Govor.API.Tests/UnitTests/Services/VerifyFriendshipTests.cs rename to Govor.Application.Tests/Services/VerifyFriendshipTests.cs index 721f69b..a4a396d 100644 --- a/Govor.API.Tests/UnitTests/Services/VerifyFriendshipTests.cs +++ b/Govor.Application.Tests/Services/VerifyFriendshipTests.cs @@ -7,7 +7,7 @@ using Govor.Data.Repositories.Exceptions; using Microsoft.Extensions.Logging; using Moq; -namespace Govor.API.Tests.UnitTests.Services; +namespace Govor.Application.Tests.Services; [TestFixture] public class VerifyFriendshipTests diff --git a/Govor.API.Tests/UnitTests/Services/Validators/UsernameValidatorTests.cs b/Govor.Application.Tests/Validators/UsernameValidatorTests.cs similarity index 100% rename from Govor.API.Tests/UnitTests/Services/Validators/UsernameValidatorTests.cs rename to Govor.Application.Tests/Validators/UsernameValidatorTests.cs diff --git a/Govor.Application/Interfaces/AdminsStuff/InvitationGenerator.cs b/Govor.Application/Infrastructure/AdminsStuff/InvitationGenerator.cs similarity index 93% rename from Govor.Application/Interfaces/AdminsStuff/InvitationGenerator.cs rename to Govor.Application/Infrastructure/AdminsStuff/InvitationGenerator.cs index e744b65..d448c49 100644 --- a/Govor.Application/Interfaces/AdminsStuff/InvitationGenerator.cs +++ b/Govor.Application/Infrastructure/AdminsStuff/InvitationGenerator.cs @@ -2,7 +2,7 @@ using Govor.API.Services.AdminsStuff.Interfaces; using Govor.Core.Models; using Govor.Core.Repositories.Invaites; -namespace Govor.Application.Interfaces.AdminsStuff; +namespace Govor.Application.Infrastructure.AdminsStuff; public class InvitationGenerator(IInvitesRepository repository) : IInvitationGenerator { diff --git a/Govor.Application/Interfaces/AdminsStuff/UsersService.cs b/Govor.Application/Infrastructure/AdminsStuff/UsersService.cs similarity index 100% rename from Govor.Application/Interfaces/AdminsStuff/UsersService.cs rename to Govor.Application/Infrastructure/AdminsStuff/UsersService.cs diff --git a/Govor.Application/Interfaces/Messages/IMessageService.cs b/Govor.Application/Interfaces/Messages/IMessageCommandService.cs similarity index 97% rename from Govor.Application/Interfaces/Messages/IMessageService.cs rename to Govor.Application/Interfaces/Messages/IMessageCommandService.cs index f4c269e..a089c16 100644 --- a/Govor.Application/Interfaces/Messages/IMessageService.cs +++ b/Govor.Application/Interfaces/Messages/IMessageCommandService.cs @@ -4,7 +4,7 @@ using Govor.Core.Models; namespace Govor.Application.Interfaces.Messages; // Combining IChatService and IGroupService functionalities relevant to messages -public interface IMessageService +public interface IMessageCommandService { Task SendMessageAsync(SendMessage messageParameters); Task EditMessageAsync(EditMessage messageParameters); diff --git a/Govor.Application/Services/AuthService.cs b/Govor.Application/Services/Authentication/AuthService.cs similarity index 98% rename from Govor.Application/Services/AuthService.cs rename to Govor.Application/Services/Authentication/AuthService.cs index f16bdb8..96685b8 100644 --- a/Govor.Application/Services/AuthService.cs +++ b/Govor.Application/Services/Authentication/AuthService.cs @@ -8,7 +8,7 @@ using Govor.Application.Interfaces.Authentication; using Govor.Core.Infrastructure.Validators; using Govor.Core.Repositories.Admins; -namespace Govor.Application.Services; +namespace Govor.Application.Services.Authentication; public class AuthService : IAccountService { diff --git a/Govor.Application/Services/JwtOption.cs b/Govor.Application/Services/Authentication/JwtOption.cs similarity index 65% rename from Govor.Application/Services/JwtOption.cs rename to Govor.Application/Services/Authentication/JwtOption.cs index 144c231..a4ebef1 100644 --- a/Govor.Application/Services/JwtOption.cs +++ b/Govor.Application/Services/Authentication/JwtOption.cs @@ -1,4 +1,4 @@ -namespace Govor.Application.Services; +namespace Govor.Application.Services.Authentication; public class JwtOption { public string SecretKeу {get; set;} diff --git a/Govor.Application/Services/JwtService.cs b/Govor.Application/Services/Authentication/JwtService.cs similarity index 95% rename from Govor.Application/Services/JwtService.cs rename to Govor.Application/Services/Authentication/JwtService.cs index dcd8334..56cf08c 100644 --- a/Govor.Application/Services/JwtService.cs +++ b/Govor.Application/Services/Authentication/JwtService.cs @@ -6,7 +6,7 @@ using Govor.Core.Models; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; -namespace Govor.Application.Services; +namespace Govor.Application.Services.Authentication; public class JwtService : IJwtService { diff --git a/Govor.Application/Services/PasswordHasher.cs b/Govor.Application/Services/Authentication/PasswordHasher.cs similarity index 87% rename from Govor.Application/Services/PasswordHasher.cs rename to Govor.Application/Services/Authentication/PasswordHasher.cs index ec83d71..99003ef 100644 --- a/Govor.Application/Services/PasswordHasher.cs +++ b/Govor.Application/Services/Authentication/PasswordHasher.cs @@ -1,6 +1,6 @@ using Govor.Core.Infrastructure.Extensions; -namespace Govor.Application.Services; +namespace Govor.Application.Services.Authentication; public class PasswordHasher : IPasswordHasher { diff --git a/Govor.Application/Services/MediaService.cs b/Govor.Application/Services/Messages/MediaService.cs similarity index 92% rename from Govor.Application/Services/MediaService.cs rename to Govor.Application/Services/Messages/MediaService.cs index 178c364..46df591 100644 --- a/Govor.Application/Services/MediaService.cs +++ b/Govor.Application/Services/Messages/MediaService.cs @@ -1,7 +1,7 @@ using Govor.Application.Interfaces; using Govor.Application.Interfaces.Medias; -namespace Govor.Application.Services; +namespace Govor.Application.Services.Messages; public class MediaService : IMediaService { diff --git a/Govor.Application/Services/MessageService.cs b/Govor.Application/Services/Messages/MessageCommandService.cs similarity index 97% rename from Govor.Application/Services/MessageService.cs rename to Govor.Application/Services/Messages/MessageCommandService.cs index e39af2f..590ee52 100644 --- a/Govor.Application/Services/MessageService.cs +++ b/Govor.Application/Services/Messages/MessageCommandService.cs @@ -8,24 +8,24 @@ using Govor.Core.Repositories.PrivateChats; using Govor.Core.Repositories.Users; using Microsoft.Extensions.Logging; -namespace Govor.Application.Services; +namespace Govor.Application.Services.Messages; -public class MessageService : IMessageService +public class MessageCommandService : IMessageCommandService { private readonly IMessagesRepository _messagesRepository; private readonly IUsersRepository _usersRepository; // For validating user recipients private readonly IGroupsRepository _groupsRepository; // For validating group recipients and fetching members private readonly IPrivateChatsRepository _privateChats; private readonly IVerifyFriendship _verifyFriendship; // For private messages - private readonly ILogger _logger; + private readonly ILogger _logger; - public MessageService( + public MessageCommandService( IMessagesRepository messagesRepository, IUsersRepository usersRepository, IGroupsRepository groupsRepository, IVerifyFriendship verifyFriendship, IPrivateChatsRepository privateChats, - ILogger logger) + ILogger logger) { _messagesRepository = messagesRepository; _usersRepository = usersRepository; diff --git a/Govor.Application/Services/MessagesLoader.cs b/Govor.Application/Services/Messages/MessagesLoader.cs similarity index 96% rename from Govor.Application/Services/MessagesLoader.cs rename to Govor.Application/Services/Messages/MessagesLoader.cs index 406a974..fc4cca5 100644 --- a/Govor.Application/Services/MessagesLoader.cs +++ b/Govor.Application/Services/Messages/MessagesLoader.cs @@ -4,7 +4,7 @@ using Govor.Core.Repositories.Groups; using Govor.Core.Repositories.Messages; using Govor.Data.Repositories.Exceptions; -namespace Govor.Application.Services; +namespace Govor.Application.Services.Messages; public class MessagesLoader : IMessagesLoader { diff --git a/Govor.sln b/Govor.sln index d3eeb87..8c4b6c7 100644 --- a/Govor.sln +++ b/Govor.sln @@ -21,6 +21,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Govor.Contracts", "Govor.Co EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Govor.Application", "Govor.Application\Govor.Application.csproj", "{FC5EDCA8-FD58-4078-8FB1-2BDBB2F6CA3E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Govor.Application.Tests", "Govor.Application.Tests\Govor.Application.Tests.csproj", "{F56A64DF-2938-4BE0-83F2-B86429F19259}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -55,6 +57,10 @@ Global {FC5EDCA8-FD58-4078-8FB1-2BDBB2F6CA3E}.Debug|Any CPU.Build.0 = Debug|Any CPU {FC5EDCA8-FD58-4078-8FB1-2BDBB2F6CA3E}.Release|Any CPU.ActiveCfg = Release|Any CPU {FC5EDCA8-FD58-4078-8FB1-2BDBB2F6CA3E}.Release|Any CPU.Build.0 = Release|Any CPU + {F56A64DF-2938-4BE0-83F2-B86429F19259}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F56A64DF-2938-4BE0-83F2-B86429F19259}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F56A64DF-2938-4BE0-83F2-B86429F19259}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F56A64DF-2938-4BE0-83F2-B86429F19259}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {15031CBD-F319-4755-BA91-B86F20BD8E37} = {4ED5259A-6FB4-4D89-8E6B-4778DC68F7D4} @@ -64,5 +70,6 @@ Global {E4EDB179-7EB5-468D-9C1F-0CBE2E5E459E} = {114F53C1-B0AB-4BA0-9E36-0E811D1B3776} {4E94907F-BE20-42A6-AB15-637850FEAD11} = {114F53C1-B0AB-4BA0-9E36-0E811D1B3776} {FC5EDCA8-FD58-4078-8FB1-2BDBB2F6CA3E} = {114F53C1-B0AB-4BA0-9E36-0E811D1B3776} + {F56A64DF-2938-4BE0-83F2-B86429F19259} = {4ED5259A-6FB4-4D89-8E6B-4778DC68F7D4} EndGlobalSection EndGlobal From d9dfaff079f1cf9c6a03c3053ffe46f7eaf4f4c0 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Thu, 10 Jul 2025 18:11:46 +0700 Subject: [PATCH 040/101] Refactor EF tests --- Govor.API.Tests/Govor.API.Tests.csproj | 4 +++ Govor.Data.Tests/Govor.Data.Tests.csproj | 30 +++++++++++++++++++ .../Repositories/AdminsRepositoryTests.cs | 3 +- .../FriendshipsRepositoryTests.cs | 2 +- .../Repositories/GroupRepositoryTests.cs | 2 +- .../Repositories/InvitesRepositoryTests.cs | 2 +- .../Repositories/MediaAttachmentsTests.cs | 2 +- .../Repositories/MessagesRepositoryTests.cs | 2 +- .../PrivateChatsRepositoryTests.cs | 2 +- .../Repositories/UsersRepositoryTests.cs | 2 +- Govor.sln | 7 +++++ 11 files changed, 49 insertions(+), 9 deletions(-) create mode 100644 Govor.Data.Tests/Govor.Data.Tests.csproj rename {Govor.API.Tests/IntegrationTests/EF => Govor.Data.Tests}/Repositories/AdminsRepositoryTests.cs (98%) rename {Govor.API.Tests/IntegrationTests/EF => Govor.Data.Tests}/Repositories/FriendshipsRepositoryTests.cs (99%) rename {Govor.API.Tests/IntegrationTests/EF => Govor.Data.Tests}/Repositories/GroupRepositoryTests.cs (99%) rename {Govor.API.Tests/IntegrationTests/EF => Govor.Data.Tests}/Repositories/InvitesRepositoryTests.cs (99%) rename {Govor.API.Tests/IntegrationTests/EF => Govor.Data.Tests}/Repositories/MediaAttachmentsTests.cs (99%) rename {Govor.API.Tests/IntegrationTests/EF => Govor.Data.Tests}/Repositories/MessagesRepositoryTests.cs (99%) rename {Govor.API.Tests/IntegrationTests/EF => Govor.Data.Tests}/Repositories/PrivateChatsRepositoryTests.cs (99%) rename {Govor.API.Tests/IntegrationTests/EF => Govor.Data.Tests}/Repositories/UsersRepositoryTests.cs (99%) diff --git a/Govor.API.Tests/Govor.API.Tests.csproj b/Govor.API.Tests/Govor.API.Tests.csproj index 2ef9646..b74c1ac 100644 --- a/Govor.API.Tests/Govor.API.Tests.csproj +++ b/Govor.API.Tests/Govor.API.Tests.csproj @@ -27,4 +27,8 @@ + + + + diff --git a/Govor.Data.Tests/Govor.Data.Tests.csproj b/Govor.Data.Tests/Govor.Data.Tests.csproj new file mode 100644 index 0000000..812ca35 --- /dev/null +++ b/Govor.Data.Tests/Govor.Data.Tests.csproj @@ -0,0 +1,30 @@ + + + + net9.0 + latest + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + diff --git a/Govor.API.Tests/IntegrationTests/EF/Repositories/AdminsRepositoryTests.cs b/Govor.Data.Tests/Repositories/AdminsRepositoryTests.cs similarity index 98% rename from Govor.API.Tests/IntegrationTests/EF/Repositories/AdminsRepositoryTests.cs rename to Govor.Data.Tests/Repositories/AdminsRepositoryTests.cs index 0790904..ed6b383 100644 --- a/Govor.API.Tests/IntegrationTests/EF/Repositories/AdminsRepositoryTests.cs +++ b/Govor.Data.Tests/Repositories/AdminsRepositoryTests.cs @@ -5,9 +5,8 @@ using Govor.Data; using Govor.Data.Repositories; using Govor.Data.Repositories.Exceptions; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -namespace Govor.API.Tests.IntegrationTests.EF.Repositories; +namespace Govor.Data.Tests.Repositories; [TestFixture] public class AdminsRepositoryTests diff --git a/Govor.API.Tests/IntegrationTests/EF/Repositories/FriendshipsRepositoryTests.cs b/Govor.Data.Tests/Repositories/FriendshipsRepositoryTests.cs similarity index 99% rename from Govor.API.Tests/IntegrationTests/EF/Repositories/FriendshipsRepositoryTests.cs rename to Govor.Data.Tests/Repositories/FriendshipsRepositoryTests.cs index 6394447..8b0a905 100644 --- a/Govor.API.Tests/IntegrationTests/EF/Repositories/FriendshipsRepositoryTests.cs +++ b/Govor.Data.Tests/Repositories/FriendshipsRepositoryTests.cs @@ -6,7 +6,7 @@ using Govor.Data.Repositories; using Govor.Data.Repositories.Exceptions; using Microsoft.EntityFrameworkCore; -namespace Govor.API.Tests.IntegrationTests.EF.Repositories; +namespace Govor.Data.Tests.Repositories; [TestFixture] public class FriendshipsRepositoryTests diff --git a/Govor.API.Tests/IntegrationTests/EF/Repositories/GroupRepositoryTests.cs b/Govor.Data.Tests/Repositories/GroupRepositoryTests.cs similarity index 99% rename from Govor.API.Tests/IntegrationTests/EF/Repositories/GroupRepositoryTests.cs rename to Govor.Data.Tests/Repositories/GroupRepositoryTests.cs index 3fac8c6..35fb5d3 100644 --- a/Govor.API.Tests/IntegrationTests/EF/Repositories/GroupRepositoryTests.cs +++ b/Govor.Data.Tests/Repositories/GroupRepositoryTests.cs @@ -6,7 +6,7 @@ using Govor.Data.Repositories; using Govor.Data.Repositories.Exceptions; using Microsoft.EntityFrameworkCore; -namespace Govor.API.Tests.IntegrationTests.EF.Repositories; +namespace Govor.Data.Tests.Repositories; [TestFixture] public class GroupRepositoryTests diff --git a/Govor.API.Tests/IntegrationTests/EF/Repositories/InvitesRepositoryTests.cs b/Govor.Data.Tests/Repositories/InvitesRepositoryTests.cs similarity index 99% rename from Govor.API.Tests/IntegrationTests/EF/Repositories/InvitesRepositoryTests.cs rename to Govor.Data.Tests/Repositories/InvitesRepositoryTests.cs index a9569e7..413d23f 100644 --- a/Govor.API.Tests/IntegrationTests/EF/Repositories/InvitesRepositoryTests.cs +++ b/Govor.Data.Tests/Repositories/InvitesRepositoryTests.cs @@ -6,7 +6,7 @@ using Govor.Data.Repositories; using Govor.Data.Repositories.Exceptions; using Microsoft.EntityFrameworkCore; -namespace Govor.API.Tests.IntegrationTests.EF.Repositories; +namespace Govor.Data.Tests.Repositories; [TestFixture] public class InvitesRepositoryTests diff --git a/Govor.API.Tests/IntegrationTests/EF/Repositories/MediaAttachmentsTests.cs b/Govor.Data.Tests/Repositories/MediaAttachmentsTests.cs similarity index 99% rename from Govor.API.Tests/IntegrationTests/EF/Repositories/MediaAttachmentsTests.cs rename to Govor.Data.Tests/Repositories/MediaAttachmentsTests.cs index bea247f..54331a6 100644 --- a/Govor.API.Tests/IntegrationTests/EF/Repositories/MediaAttachmentsTests.cs +++ b/Govor.Data.Tests/Repositories/MediaAttachmentsTests.cs @@ -6,7 +6,7 @@ using Govor.Data.Repositories; using Govor.Data.Repositories.Exceptions; using Microsoft.EntityFrameworkCore; -namespace Govor.API.Tests.IntegrationTests.EF.Repositories; +namespace Govor.Data.Tests.Repositories; [TestFixture] public class MediaAttachmentsTests diff --git a/Govor.API.Tests/IntegrationTests/EF/Repositories/MessagesRepositoryTests.cs b/Govor.Data.Tests/Repositories/MessagesRepositoryTests.cs similarity index 99% rename from Govor.API.Tests/IntegrationTests/EF/Repositories/MessagesRepositoryTests.cs rename to Govor.Data.Tests/Repositories/MessagesRepositoryTests.cs index 9b12459..e0f60e2 100644 --- a/Govor.API.Tests/IntegrationTests/EF/Repositories/MessagesRepositoryTests.cs +++ b/Govor.Data.Tests/Repositories/MessagesRepositoryTests.cs @@ -6,7 +6,7 @@ using Govor.Data.Repositories; using Govor.Data.Repositories.Exceptions; using Microsoft.EntityFrameworkCore; -namespace Govor.API.Tests.IntegrationTests.EF.Repositories; +namespace Govor.Data.Tests.Repositories; [TestFixture] public class MessagesRepositoryTests diff --git a/Govor.API.Tests/IntegrationTests/EF/Repositories/PrivateChatsRepositoryTests.cs b/Govor.Data.Tests/Repositories/PrivateChatsRepositoryTests.cs similarity index 99% rename from Govor.API.Tests/IntegrationTests/EF/Repositories/PrivateChatsRepositoryTests.cs rename to Govor.Data.Tests/Repositories/PrivateChatsRepositoryTests.cs index 387beff..7e26ff4 100644 --- a/Govor.API.Tests/IntegrationTests/EF/Repositories/PrivateChatsRepositoryTests.cs +++ b/Govor.Data.Tests/Repositories/PrivateChatsRepositoryTests.cs @@ -6,7 +6,7 @@ using Govor.Data.Repositories; using Govor.Data.Repositories.Exceptions; using Microsoft.EntityFrameworkCore; -namespace Govor.API.Tests.IntegrationTests.EF.Repositories; +namespace Govor.Data.Tests.Repositories; [TestFixture] public class PrivateChatsRepositoryTests diff --git a/Govor.API.Tests/IntegrationTests/EF/Repositories/UsersRepositoryTests.cs b/Govor.Data.Tests/Repositories/UsersRepositoryTests.cs similarity index 99% rename from Govor.API.Tests/IntegrationTests/EF/Repositories/UsersRepositoryTests.cs rename to Govor.Data.Tests/Repositories/UsersRepositoryTests.cs index 7cec3bf..6080460 100644 --- a/Govor.API.Tests/IntegrationTests/EF/Repositories/UsersRepositoryTests.cs +++ b/Govor.Data.Tests/Repositories/UsersRepositoryTests.cs @@ -6,7 +6,7 @@ using Govor.Data.Repositories; using Govor.Data.Repositories.Exceptions; using Microsoft.EntityFrameworkCore; -namespace Govor.API.Tests.IntegrationTests.EF.Repositories; +namespace Govor.Data.Tests.Repositories; [TestFixture] public class UsersRepositoryTests diff --git a/Govor.sln b/Govor.sln index 8c4b6c7..0aec0ab 100644 --- a/Govor.sln +++ b/Govor.sln @@ -23,6 +23,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Govor.Application", "Govor. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Govor.Application.Tests", "Govor.Application.Tests\Govor.Application.Tests.csproj", "{F56A64DF-2938-4BE0-83F2-B86429F19259}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Govor.Data.Tests", "Govor.Data.Tests\Govor.Data.Tests.csproj", "{CF6B23EC-932A-4998-BA95-C94CAB7B092C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -61,6 +63,10 @@ Global {F56A64DF-2938-4BE0-83F2-B86429F19259}.Debug|Any CPU.Build.0 = Debug|Any CPU {F56A64DF-2938-4BE0-83F2-B86429F19259}.Release|Any CPU.ActiveCfg = Release|Any CPU {F56A64DF-2938-4BE0-83F2-B86429F19259}.Release|Any CPU.Build.0 = Release|Any CPU + {CF6B23EC-932A-4998-BA95-C94CAB7B092C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CF6B23EC-932A-4998-BA95-C94CAB7B092C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CF6B23EC-932A-4998-BA95-C94CAB7B092C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CF6B23EC-932A-4998-BA95-C94CAB7B092C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {15031CBD-F319-4755-BA91-B86F20BD8E37} = {4ED5259A-6FB4-4D89-8E6B-4778DC68F7D4} @@ -71,5 +77,6 @@ Global {4E94907F-BE20-42A6-AB15-637850FEAD11} = {114F53C1-B0AB-4BA0-9E36-0E811D1B3776} {FC5EDCA8-FD58-4078-8FB1-2BDBB2F6CA3E} = {114F53C1-B0AB-4BA0-9E36-0E811D1B3776} {F56A64DF-2938-4BE0-83F2-B86429F19259} = {4ED5259A-6FB4-4D89-8E6B-4778DC68F7D4} + {CF6B23EC-932A-4998-BA95-C94CAB7B092C} = {4ED5259A-6FB4-4D89-8E6B-4778DC68F7D4} EndGlobalSection EndGlobal From e8e2078514d717b74cfccb0165f728e3fd37593d Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Fri, 11 Jul 2025 12:19:32 +0700 Subject: [PATCH 041/101] app.MapHub("/api/friends"); --- Dockerfile | 1 + .../Controllers/FriendsControllerTests.cs | 212 +++++++++--------- .../Controllers/Friends/FriendsController.cs | 212 ------------------ .../Friends/FriendsRequestQueryController.cs | 52 ++++- .../Friends/FriendshipController.cs | 24 +- Govor.API/Hubs/FriendsHub.cs | 67 +++++- Govor.API/Program.cs | 2 + Govor.Console/Program.cs | 2 +- Govor.Data.Tests/Govor.Data.Tests.csproj | 2 +- 9 files changed, 249 insertions(+), 325 deletions(-) delete mode 100644 Govor.API/Controllers/Friends/FriendsController.cs diff --git a/Dockerfile b/Dockerfile index 04dd83a..ce3132e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,7 @@ COPY Govor.API/*.csproj ./Govor.API/ COPY Govor.Application/*.csproj ./Govor.Application/ COPY Govor.Core/*.csproj ./Govor.Core/ COPY Govor.Data/*.csproj ./Govor.Data/ +COPY Govor.Contracts/*.csproj ./Govor.Contracts/ RUN dotnet restore # Копируем все исходники и билдим проект в Release режиме diff --git a/Govor.API.Tests/IntegrationTests/Controllers/FriendsControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/FriendsControllerTests.cs index 98618d5..cb3d70d 100644 --- a/Govor.API.Tests/IntegrationTests/Controllers/FriendsControllerTests.cs +++ b/Govor.API.Tests/IntegrationTests/Controllers/FriendsControllerTests.cs @@ -14,52 +14,52 @@ namespace Govor.API.Tests.IntegrationTests.Controllers; [TestFixture] public class FriendsControllerTests { + /* private Fixture _fixture; private Mock> _loggerMock; private Mock _friendsServiceMock; private Mock _currentUserServiceMock; private FriendsController _controller; - + [SetUp] public void SetUp() { _fixture = new Fixture(); _fixture.Behaviors.OfType().ToList().ForEach(b => _fixture.Behaviors.Remove(b)); _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); - + _loggerMock = new Mock>(); _friendsServiceMock = new Mock(); _currentUserServiceMock = new Mock(); - + _controller = new FriendsController( _loggerMock.Object, _friendsServiceMock.Object, _currentUserServiceMock.Object ); } - - // Tests for Search action + // Tests for Search action [Test] public async Task Search_ValidRequest_ReturnsOkResult() { var users = _fixture.CreateMany().ToList(); var userId = _fixture.Create(); var query = _fixture.Create(); - + _currentUserServiceMock.Setup(c => c.GetCurrentUserId()).Returns(userId); - + _friendsServiceMock.Setup(f => f.SearchUsersAsync(query, userId)) .ReturnsAsync(users); - - // Act + + // Act var result = await _controller.Search(query); - + var okResult = result as OkObjectResult; dynamic value = okResult.Value; - + List userDtos = value as List; - - // Assert + + // Assert Assert.That(value, Is.Not.Null); Assert.That(value.Count, Is.EqualTo(users.Count)); Assert.That(userDtos.Select(u => u.Id), Is.EqualTo(users.Select(u => u.Id))); @@ -68,43 +68,43 @@ public class FriendsControllerTests [Test] public async Task Search_InvalidQuery_BadRequest() { - // Act + // Act var result = await _controller.Search(string.Empty); // Assert Assert.That(result, Is.InstanceOf()); var badRequestResult = result as BadRequestObjectResult; Assert.That(badRequestResult.Value, Is.EqualTo("Query cannot be empty")); } - - + + [Test] public async Task Search_NotFound_IfThrowsSearchUsersException() { - // Arrange + // Arrange _friendsServiceMock.Setup(f => f.SearchUsersAsync(It.IsAny(), It.IsAny())) .ThrowsAsync(new SearchUsersException(_fixture.Create())); - - // Act + + // Act var result = await _controller.Search(_fixture.Create()); // Assert Assert.That(result, Is.InstanceOf()); } - + [Test] public async Task Search_StatusCode500_IfThrowsSomeException() { - // Arrange + // Arrange _friendsServiceMock.Setup(f => f.SearchUsersAsync(It.IsAny(), It.IsAny())) .ThrowsAsync(new Exception(_fixture.Create())); - - // Act + + // Act var result = await _controller.Search(_fixture.Create()); // Assert Assert.That(result, Is.InstanceOf()); var objectResult = result as ObjectResult; Assert.That(objectResult.StatusCode, Is.EqualTo(500)); } - + // Test for SendRequest action [Test] public async Task SendRequest_ValidRequest_ReturnsOk() @@ -122,66 +122,66 @@ public class FriendsControllerTests public async Task SendRequest_Throws_InvalidOperationException_ReturnsUnprocessableEntity() { var targetUserId = Guid.NewGuid(); - + _currentUserServiceMock.Setup(c => c.GetCurrentUserId()).Returns(targetUserId); - + _friendsServiceMock.Setup(f => f.SendFriendRequestAsync(targetUserId, targetUserId)) .ThrowsAsync(new InvalidOperationException()); - + var result = await _controller.SendRequest(targetUserId); Assert.That(result, Is.InstanceOf()); } - + [Test] public async Task SendRequest_Throws_RequestAlreadySentException_ReturnsConflict() { var targetUserId = Guid.NewGuid(); var currentUserId = Guid.NewGuid(); - + _currentUserServiceMock.Setup(c => c.GetCurrentUserId()).Returns(currentUserId); - + _friendsServiceMock.Setup(f => f.SendFriendRequestAsync(currentUserId, targetUserId)) .ThrowsAsync(new RequestAlreadySentException(currentUserId, targetUserId)); - + var result = await _controller.SendRequest(targetUserId); Assert.That(result, Is.InstanceOf()); } - + [Test] public async Task SendRequest_StatusCode500_IfThrowsSomeException() { - // Arrange + // Arrange _friendsServiceMock.Setup(f => f.SendFriendRequestAsync(It.IsAny(), It.IsAny())) .ThrowsAsync(new Exception(_fixture.Create())); - - // Act + + // Act var result = await _controller.Search(_fixture.Create()); - + // Assert Assert.That(result, Is.InstanceOf()); var objectResult = result as ObjectResult; Assert.That(objectResult.StatusCode, Is.EqualTo(500)); } - - // Tests for GetIncomingRequests action + + // Tests for GetIncomingRequests action [Test] public async Task GetIncomingRequests_ValidRequest_ReturnsOkResult() { // Arrange var currentId = _fixture.Create(); var friendships = _fixture.CreateMany().ToList(); - + _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) .Returns(currentId); - + _friendsServiceMock.Setup(f => f.GetIncomingRequestsAsync(currentId)) .ReturnsAsync(friendships); - // Act + // Act var result = await _controller.GetIncomingRequests(); - - // Assert + + // Assert Assert.That(result, Is.InstanceOf()); var okResult = result as OkObjectResult; List value = okResult.Value as List; @@ -194,17 +194,17 @@ public class FriendsControllerTests { // Arrange var currentId = _fixture.Create(); - + _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) .Returns(currentId); - + _friendsServiceMock.Setup(f => f.GetIncomingRequestsAsync(currentId)) .ThrowsAsync(new InvalidOperationException()); - - // Act + + // Act var result = await _controller.GetIncomingRequests(); - - // Assert + + // Assert Assert.That(result, Is.InstanceOf()); var badRequestResult = result as BadRequestObjectResult; dynamic value = badRequestResult.Value; @@ -216,169 +216,171 @@ public class FriendsControllerTests { // Arrange var currentId = _fixture.Create(); - + _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) .Returns(currentId); - + _friendsServiceMock.Setup(f => f.GetIncomingRequestsAsync(currentId)) .ThrowsAsync(new Exception()); - - // Act + + // Act var result = await _controller.GetIncomingRequests(); - + // Assert Assert.That(result, Is.InstanceOf()); var objectResult = result as ObjectResult; Assert.That(objectResult.StatusCode, Is.EqualTo(500)); } - - // Tests for AcceptFriend action + + // Tests for AcceptFriend action [Test] public async Task AcceptFriend_ValidRequest_ReturnsOkResult() { - // Arrange + // Arrange var currentId = Guid.NewGuid(); var targetUserId = Guid.NewGuid(); - + _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) .Returns(currentId); - // Act + // Act var result = await _controller.AcceptFriend(targetUserId); - - // Assert + + // Assert Assert.That(result, Is.InstanceOf()); } - + [Test] public async Task AcceptFriend_InvalidOperationException_ReturnsNotFound() { - // Arrange + // Arrange var currentId = Guid.NewGuid(); var targetUserId = Guid.NewGuid(); - + _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) .Returns(currentId); - + _friendsServiceMock.Setup(f => f.AcceptFriendRequestAsync(targetUserId, currentId)) .ThrowsAsync(new InvalidOperationException()); - - // Act + + // Act var result = await _controller.AcceptFriend(targetUserId); - - // Assert + + // Assert Assert.That(result, Is.InstanceOf()); } [Test] public async Task AcceptFriend_UnauthorizedAccessException_ReturnsForbid() { - // Arrange + // Arrange var currentId = Guid.NewGuid(); var targetUserId = Guid.NewGuid(); - + _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) .Returns(currentId); - + _friendsServiceMock.Setup(f => f.AcceptFriendRequestAsync(targetUserId, currentId)) .ThrowsAsync(new UnauthorizedAccessException()); - - // Act + + // Act var result = await _controller.AcceptFriend(targetUserId); - - // Assert + + // Assert Assert.That(result, Is.InstanceOf()); } - + [Test] public async Task AcceptFriend_Exception_ReturnsStatusCode500() { - // Arrange + // Arrange var currentId = Guid.NewGuid(); var targetUserId = Guid.NewGuid(); - + _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) .Returns(currentId); - + _friendsServiceMock.Setup(f => f.AcceptFriendRequestAsync(targetUserId, currentId)) .ThrowsAsync(new Exception()); - - // Act + + // Act var result = await _controller.AcceptFriend(targetUserId); - - // Assert + + // Assert Assert.That(result, Is.InstanceOf()); var objectResult = result as ObjectResult; Assert.That(objectResult.StatusCode, Is.EqualTo(500)); } - - // Tests for GetFriends action + + // Tests for GetFriends action [Test] public async Task GetFriends_ValidRequest_ReturnsOkResult() { - // Arrange + // Arrange var currentId = _fixture.Create(); var users = _fixture.CreateMany().ToList(); - + _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) .Returns(currentId); _friendsServiceMock.Setup(f => f.GetFriendsAsync(currentId)) .ReturnsAsync(users); - // Act + // Act var result = await _controller.GetFriends(); - - // Assert + + // Assert Assert.That(result, Is.InstanceOf()); var okResult = result as OkObjectResult; List value = okResult.Value as List; Assert.That(value, Is.Not.Null); Assert.That(value.Count, Is.EqualTo(users.Count)); } - + [Test] public async Task GetFriends_InvalidOperationException_ReturnsBadRequest() { - // Arrange + // Arrange var currentId = _fixture.Create(); var users = _fixture.CreateMany().ToList(); - + _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) .Returns(currentId); _friendsServiceMock.Setup(f => f.GetFriendsAsync(currentId)) .ThrowsAsync(new InvalidOperationException()); - - // Act + + // Act var result = await _controller.GetFriends(); - - // Assert + + // Assert Assert.That(result, Is.InstanceOf()); } - + [Test] public async Task GetFriends_Exception_ReturnsStatusCode500() { - // Arrange + // Arrange var currentId = _fixture.Create(); var users = _fixture.CreateMany().ToList(); - + _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) .Returns(currentId); _friendsServiceMock.Setup(f => f.GetFriendsAsync(currentId)) .ThrowsAsync(new Exception()); - - // Act + + // Act var result = await _controller.GetFriends(); - - // Assert + + // Assert Assert.That(result, Is.InstanceOf()); var objectResult = result as ObjectResult; Assert.That(objectResult.StatusCode, Is.EqualTo(500)); } + [TearDown] public void TearDown() { _controller.Dispose(); } + */ } \ No newline at end of file diff --git a/Govor.API/Controllers/Friends/FriendsController.cs b/Govor.API/Controllers/Friends/FriendsController.cs deleted file mode 100644 index c333998..0000000 --- a/Govor.API/Controllers/Friends/FriendsController.cs +++ /dev/null @@ -1,212 +0,0 @@ -using Govor.Application.Exceptions.FriendsService; -using Govor.Application.Interfaces; -using Govor.Application.Interfaces.Infrastructure.Extensions; -using Govor.Contracts.DTOs; -using Govor.Core.Models; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; - -namespace Govor.API.Controllers; - -[ApiController] -[Route("api/[controller]")] -[Authorize(Roles = "User,Admin")] -public class FriendsController : Controller -{ - private readonly ILogger _logger; - private readonly IFriendsService _friendsService; - private readonly ICurrentUserService _currentUserService; - - public FriendsController(ILogger logger, - IFriendsService friendsService, - ICurrentUserService currentUserService) - { - _logger = logger; - _friendsService = friendsService; - _currentUserService = currentUserService; - } - - [HttpGet("search")] // api/friends/search?query= - public async Task Search(string query) - { - if (string.IsNullOrWhiteSpace(query)) - return BadRequest("Query cannot be empty"); - - try - { - var result = await _friendsService.SearchUsersAsync(query, _currentUserService.GetCurrentUserId()); - return Ok(BuildUserDtos(result)); - } - catch (SearchUsersException ex) - { - _logger.LogWarning(ex, ex.Message); - return NotFound(new { error = ex.Message }); - } - catch (Exception ex) - { - _logger.LogError(ex, ex.Message); - return StatusCode(500, new { error = "Internal error during user search." }); - } - } - - [HttpPost("request")] - public async Task SendRequest(Guid targetUserId) - { - if (targetUserId == Guid.Empty) - return BadRequest("Target user ID is invalid"); - - try - { - await _friendsService.SendFriendRequestAsync(_currentUserService.GetCurrentUserId(), targetUserId); - return Ok(new { message = "Friend request sent successfully." }); - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, "Tried to send request to self"); - return UnprocessableEntity(new { error = ex.Message }); - } - catch (RequestAlreadySentException ex) - { - _logger.LogWarning(ex, ex.Message); - return Conflict(new { error = ex.Message }); - } - catch (Exception ex) - { - _logger.LogError(ex, ex.Message); - return StatusCode(500, new { error = "Failed to send friend request." }); - } - } - - [HttpGet("requests")] - public async Task GetIncomingRequests() - { - try - { - var result = await _friendsService.GetIncomingRequestsAsync(_currentUserService.GetCurrentUserId()); - return Ok(BuildFriendshipDtos(result)); - } - catch (InvalidOperationException ex) - { - _logger.LogError(ex, ex.Message); - return BadRequest("Failed to get friend requests. User data missing."); - } - catch (Exception ex) - { - _logger.LogError(ex, ex.Message); - return StatusCode(500, new { error = "Internal server error." }); - } - } - - [HttpGet("responses")] - public async Task GetResponses() - { - try - { - var result = await _friendsService.GetResponsesAsync(_currentUserService.GetCurrentUserId()); - return Ok(BuildFriendshipDtos(result)); - } - catch (InvalidOperationException ex) - { - _logger.LogError(ex, ex.Message); - return Ok(Array.Empty()); - } - catch (Exception ex) - { - _logger.LogError(ex, ex.Message); - return StatusCode(500, new { error = "Internal server error." }); - } - } - - [HttpPost("accept")] - public async Task AcceptFriend([FromQuery] Guid friendshipId) - { - if (friendshipId == Guid.Empty) - return BadRequest("Requester ID is invalid"); - - try - { - await _friendsService.AcceptFriendRequestAsync(friendshipId, _currentUserService.GetCurrentUserId()); - return Ok(new { message = "Friend request accepted." }); - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, ex.Message); - return NotFound(new { error = ex.Message }); - } - catch (UnauthorizedAccessException ex) - { - _logger.LogWarning(ex, ex.Message); - return Forbid(); - } - catch (Exception ex) - { - _logger.LogError(ex, ex.Message); - return StatusCode(500, new { error = "Failed to accept friend request." }); - } - } - - [HttpPost("reject")] - public async Task RejectFriend([FromQuery] Guid friendshipId) - { - if (friendshipId == Guid.Empty) - return BadRequest("Requester ID is invalid"); - - try - { - await _friendsService.RejectFriendRequestAsync(friendshipId, _currentUserService.GetCurrentUserId()); - return Ok(new { message = "Friend request rejected." }); - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, ex.Message); - return NotFound(new { error = ex.Message }); - } - catch (UnauthorizedAccessException ex) - { - _logger.LogWarning(ex, ex.Message); - return Forbid(); - } - catch (Exception ex) - { - _logger.LogError(ex, ex.Message); - return StatusCode(500, new { error = "Failed to accept friend request." }); - } - } - - [HttpGet] - public async Task GetFriends() - { - try - { - var result = await _friendsService.GetFriendsAsync(_currentUserService.GetCurrentUserId()); - return Ok(BuildUserDtos(result)); - } - catch (InvalidOperationException ex) - { - _logger.LogError(ex, ex.Message); - return Ok(Array.Empty()); - } - catch (Exception ex) - { - _logger.LogError(ex, ex.Message); - return StatusCode(500, new { error = "Internal server error." }); - } - } - - private List BuildUserDtos(IEnumerable users) => users.Select(user => new UserDto - { - Id = user.Id, - Username = user.Username, - Description = user.Description, - WasOnline = user.WasOnline, - IconId = user.IconId - }).ToList(); - - private List BuildFriendshipDtos(IEnumerable friendships) => friendships.Select(f => new FriendshipDto - { - Id = f.Id, - Status = f.Status, - AddresseeId = f.AddresseeId, - RequesterId = f.RequesterId, - }).ToList(); -} diff --git a/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs b/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs index d01c507..29fc7ee 100644 --- a/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs +++ b/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs @@ -1,5 +1,7 @@ using Govor.Application.Interfaces.Friends; using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.Contracts.DTOs; +using Govor.Core.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -10,11 +12,11 @@ namespace Govor.API.Controllers.Friends; [Route("api/friends")] public class FriendsRequestQueryController : Controller { - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly IFriendRequestQueryService _friendsService; private readonly ICurrentUserService _currentUserService; - public FriendsRequestQueryController(ILogger logger, + public FriendsRequestQueryController(ILogger logger, IFriendRequestQueryService friendsService, ICurrentUserService currentUserService) { @@ -23,5 +25,51 @@ public class FriendsRequestQueryController : Controller _currentUserService = currentUserService; } + [HttpGet("requests")] // api/friends/requests + public async Task GetIncomingRequests() + { + try + { + var result = await _friendsService.GetIncomingAsync(_currentUserService.GetCurrentUserId()); + return Ok(BuildFriendshipDtos(result)); + } + catch (InvalidOperationException ex) + { + _logger.LogError(ex, ex.Message); + return BadRequest("Failed to get friend requests. User data missing."); + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + return StatusCode(500, new { error = "Internal server error." }); + } + } + [HttpGet("responses")]// api/friends/responses + public async Task GetResponses() + { + try + { + var result = await _friendsService.GetResponsesAsync(_currentUserService.GetCurrentUserId()); + return Ok(BuildFriendshipDtos(result)); + } + catch (InvalidOperationException ex) + { + _logger.LogError(ex, ex.Message); + return Ok(Array.Empty()); + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + return StatusCode(500, new { error = "Internal server error." }); + } + } + + private List BuildFriendshipDtos(IEnumerable friendships) => friendships.Select(f => new FriendshipDto + { + Id = f.Id, + Status = f.Status, + AddresseeId = f.AddresseeId, + RequesterId = f.RequesterId, + }).ToList(); } \ No newline at end of file diff --git a/Govor.API/Controllers/Friends/FriendshipController.cs b/Govor.API/Controllers/Friends/FriendshipController.cs index 8daf319..2aff5e0 100644 --- a/Govor.API/Controllers/Friends/FriendshipController.cs +++ b/Govor.API/Controllers/Friends/FriendshipController.cs @@ -10,12 +10,12 @@ namespace Govor.API.Controllers.Friends; [Route("api/friends")] public class FriendshipController : Controller { - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly IFriendshipService _friendsService; //private readonly IUserDtoBuilder _builder; private readonly ICurrentUserService _currentUserService; - public FriendshipController(ILogger logger, + public FriendshipController(ILogger logger, IFriendshipService friendsService, ICurrentUserService currentUserService) { @@ -47,6 +47,26 @@ public class FriendshipController : Controller } } + [HttpGet] // api/friends + public async Task GetFriends() + { + try + { + var result = await _friendsService.GetFriendsAsync(_currentUserService.GetCurrentUserId()); + return Ok(BuildUserDtos(result)); + } + catch (InvalidOperationException ex) + { + _logger.LogError(ex, ex.Message); + return Ok(Array.Empty()); + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + return StatusCode(500, new { error = "Internal server error." }); + } + } + private List BuildUserDtos(IEnumerable users) => users.Select(user => new UserDto { Id = user.Id, diff --git a/Govor.API/Hubs/FriendsHub.cs b/Govor.API/Hubs/FriendsHub.cs index 6fe173a..1e9e309 100644 --- a/Govor.API/Hubs/FriendsHub.cs +++ b/Govor.API/Hubs/FriendsHub.cs @@ -11,15 +11,61 @@ public class FriendsHub : Hub private readonly ILogger _logger; private readonly IFriendRequestCommandService _friendRequestService; private readonly ICurrentUserService _currentUserService; - + public FriendsHub(IFriendRequestCommandService friendRequestService, ICurrentUserService currentUserService, ILogger logger) { _friendRequestService = friendRequestService; _currentUserService = currentUserService; _logger = logger; } + + public override async Task OnConnectedAsync() + { + var userId = GetUserId(); + if (userId == Guid.Empty) + { + _logger.LogWarning("User connected with invalid UserID claim."); + Context.Abort(); // Abort connection if userID is invalid + return; + } - public async Task> SendRequest(Guid targetUserId) + // Add user to their own group (for private messages and notifications) + await Groups.AddToGroupAsync(Context.ConnectionId, userId.ToString()); + _logger.LogInformation("User {UserId} connected with ConnectionId {ConnectionId} and added to their group", + userId, Context.ConnectionId); + + + await base.OnConnectedAsync(); + } + public override async Task OnDisconnectedAsync(Exception? exception) + { + var userId = + GetUserId(suppressException: true); // Suppress exception if userID is not found (e.g. connection aborted early) + if (userId != Guid.Empty) + { + // Remove user from their own group + await Groups.RemoveFromGroupAsync(Context.ConnectionId, userId.ToString()); + _logger.LogInformation( + "User {UserId} disconnected with ConnectionId {ConnectionId} and removed from their group", userId, + Context.ConnectionId); + } + else if (exception != null) + { + _logger.LogWarning(exception, + "User disconnected with an exception and invalid UserID claim. ConnectionId: {ConnectionId}", + Context.ConnectionId); + } + else + { + _logger.LogInformation( + "User disconnected with no exception and invalid UserID claim. ConnectionId: {ConnectionId}", + Context.ConnectionId); + } + + await base.OnDisconnectedAsync(exception); + } + + public async Task> SendRequest(Guid targetUserId) { try { @@ -110,4 +156,21 @@ public class FriendsHub : Hub return HubResult.Error("Unexpected error! Please try later!"); } } + + private Guid GetUserId(bool suppressException = false) + { + var userIdClaim = Context.User?.FindFirst("userId")?.Value; + if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) + { + if (!suppressException) + { + _logger.LogError("Could not retrieve sender userId. Claim was: {UserIDClaim}", userIdClaim); + throw new UnauthorizedAccessException("userID claim is missing or invalid."); + } + + return Guid.Empty; + } + + return userId; + } } \ No newline at end of file diff --git a/Govor.API/Program.cs b/Govor.API/Program.cs index fd3e0ac..8f285d3 100644 --- a/Govor.API/Program.cs +++ b/Govor.API/Program.cs @@ -120,7 +120,9 @@ app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); + app.MapHub("/api/chats"); +app.MapHub("/api/friends"); app.MapSwagger().RequireAuthorization(); diff --git a/Govor.Console/Program.cs b/Govor.Console/Program.cs index 3383d4b..7bfbf4a 100644 --- a/Govor.Console/Program.cs +++ b/Govor.Console/Program.cs @@ -28,7 +28,7 @@ namespace Govor.ConsoleClient //static string baseUrl = "https://govor-team-govor-88b3.twc1.net"; static string baseUrl = "https://localhost:7155"; static string? AuthToken = null; - static HttpClientService HttpService = new(baseUrl); // поменяй URL на свой + static HttpClientService HttpService = new(baseUrl); private static FriendsClient? friendsClient; static HubConnection? _hubConnection; static Dictionary> ChatHistory = new(); diff --git a/Govor.Data.Tests/Govor.Data.Tests.csproj b/Govor.Data.Tests/Govor.Data.Tests.csproj index 812ca35..8ad7ddc 100644 --- a/Govor.Data.Tests/Govor.Data.Tests.csproj +++ b/Govor.Data.Tests/Govor.Data.Tests.csproj @@ -1,7 +1,7 @@  - net9.0 + net8.0 latest enable enable From b42c7d6de6d5ac16c76a75377ea38e218c9dcf2e Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 11 Jul 2025 05:47:22 +0000 Subject: [PATCH 042/101] I refactored Govor.Console to use the command pattern. Here's what I did: - I implemented a command-based architecture for Govor.Console. - Each of your actions (e.g., login, send friend request, list friends) is now encapsulated in its own command class inheriting from BaseCommand. - I integrated friend-related functionalities (search, list, send/accept/reject request, block/unblock) using a mix of SignalR Hub and REST API calls. - I included admin functionalities for managing friendships (list all, list user's, remove). - I added a /help (-h) command to display available commands and their usage. - I refactored Program.cs to handle command parsing and execution. - I updated FriendsClient.cs, removing methods now handled directly by commands via SignalR. - I added SignalR event handlers for real-time notifications (friend requests, accepts, etc.). --- .../Commands/AcceptFriendRequestCommand.cs | 41 ++ .../AdminListAllFriendshipsCommand.cs | 60 +++ .../AdminListUserFriendshipsCommand.cs | 70 ++++ .../Commands/AdminRemoveFriendshipCommand.cs | 64 +++ Govor.Console/Commands/BaseCommand.cs | 65 +++ Govor.Console/Commands/BlockUserCommand.cs | 42 ++ Govor.Console/Commands/HelpCommand.cs | 49 +++ Govor.Console/Commands/ICommand.cs | 8 + Govor.Console/Commands/ListFriendsCommand.cs | 40 ++ .../Commands/ListIncomingRequestsCommand.cs | 44 ++ .../Commands/ListOutgoingRequestsCommand.cs | 50 +++ Govor.Console/Commands/LoginCommand.cs | 50 +++ Govor.Console/Commands/RegisterCommand.cs | 53 +++ .../Commands/RejectFriendRequestCommand.cs | 42 ++ Govor.Console/Commands/SearchUserCommand.cs | 51 +++ .../Commands/SendFriendRequestCommand.cs | 41 ++ Govor.Console/Commands/UnblockUserCommand.cs | 42 ++ Govor.Console/FriendsClient.cs | 27 +- Govor.Console/Program.cs | 393 +++++++++--------- 19 files changed, 1030 insertions(+), 202 deletions(-) create mode 100644 Govor.Console/Commands/AcceptFriendRequestCommand.cs create mode 100644 Govor.Console/Commands/AdminListAllFriendshipsCommand.cs create mode 100644 Govor.Console/Commands/AdminListUserFriendshipsCommand.cs create mode 100644 Govor.Console/Commands/AdminRemoveFriendshipCommand.cs create mode 100644 Govor.Console/Commands/BaseCommand.cs create mode 100644 Govor.Console/Commands/BlockUserCommand.cs create mode 100644 Govor.Console/Commands/HelpCommand.cs create mode 100644 Govor.Console/Commands/ICommand.cs create mode 100644 Govor.Console/Commands/ListFriendsCommand.cs create mode 100644 Govor.Console/Commands/ListIncomingRequestsCommand.cs create mode 100644 Govor.Console/Commands/ListOutgoingRequestsCommand.cs create mode 100644 Govor.Console/Commands/LoginCommand.cs create mode 100644 Govor.Console/Commands/RegisterCommand.cs create mode 100644 Govor.Console/Commands/RejectFriendRequestCommand.cs create mode 100644 Govor.Console/Commands/SearchUserCommand.cs create mode 100644 Govor.Console/Commands/SendFriendRequestCommand.cs create mode 100644 Govor.Console/Commands/UnblockUserCommand.cs diff --git a/Govor.Console/Commands/AcceptFriendRequestCommand.cs b/Govor.Console/Commands/AcceptFriendRequestCommand.cs new file mode 100644 index 0000000..5a54b82 --- /dev/null +++ b/Govor.Console/Commands/AcceptFriendRequestCommand.cs @@ -0,0 +1,41 @@ +using System; +using System.Threading.Tasks; + +namespace Govor.ConsoleClient.Commands +{ + public class AcceptFriendRequestCommand : BaseCommand + { + public override async Task ExecuteAsync(string? argument) + { + if (!EnsureLoggedIn() || !EnsureHubConnection()) return; + + Guid friendshipId; + if (string.IsNullOrWhiteSpace(argument) || !Guid.TryParse(argument, out friendshipId)) + { + Console.Write("Введите ID заявки, которую хотите принять: "); + var input = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(input) || !Guid.TryParse(input, out friendshipId)) + { + Console.WriteLine("[Ошибка] Неверный или пустой ID заявки."); + return; + } + } + + try + { + // API uses Hub for this: AcceptFriendRequest(Guid friendshipId) + await HubConnection.InvokeAsync("AcceptFriendRequest", friendshipId); + Console.WriteLine("Заявка в друзья принята."); + } + catch (Exception ex) + { + Console.WriteLine($"[Ошибка принятия заявки] {ex.Message}"); + } + } + + public override string GetHelp() + { + return "/accept [ID_заявки] - Принять входящую заявку в друзья. Если ID не указан, запросит ввод."; + } + } +} diff --git a/Govor.Console/Commands/AdminListAllFriendshipsCommand.cs b/Govor.Console/Commands/AdminListAllFriendshipsCommand.cs new file mode 100644 index 0000000..24aef5a --- /dev/null +++ b/Govor.Console/Commands/AdminListAllFriendshipsCommand.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Json; +using System.Threading.Tasks; +using Govor.Contracts.DTOs; // Required for FriendshipDto + +namespace Govor.ConsoleClient.Commands +{ + public class AdminListAllFriendshipsCommand : BaseCommand + { + public override async Task ExecuteAsync(string? argument) + { + if (!EnsureLoggedIn()) return; + // Consider adding an admin role check here if possible, + // though the API itself is protected by [Authorize(Roles = "Admin")] + + Console.WriteLine("Получение всех дружеских связей (только для администраторов)..."); + try + { + var response = await HttpClientService.GetAsync("api/admin/Friendships"); + if (response.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + Console.WriteLine("[Ошибка] Доступ запрещен. Эта команда только для администраторов."); + return; + } + response.EnsureSuccessStatusCode(); + + var friendships = await response.Content.ReadFromJsonAsync>(); + + if (friendships != null && friendships.Any()) + { + Console.WriteLine("Все дружеские связи в системе:"); + foreach (var f in friendships) + { + Console.WriteLine($"- ID: {f.Id}, Пользователь1: {f.RequesterId}, Пользователь2: {f.AddresseeId}, Статус: {f.Status}"); + } + } + else + { + Console.WriteLine("Дружеские связи не найдены в системе."); + } + } + catch (HttpRequestException httpEx) when (httpEx.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + Console.WriteLine("[Ошибка] Доступ запрещен. Эта команда только для администраторов."); + } + catch (Exception ex) + { + Console.WriteLine($"[Ошибка] {ex.Message}"); + } + } + + public override string GetHelp() + { + return "/adminlistallfs - (Админ) Показать все дружеские связи в системе."; + } + } +} diff --git a/Govor.Console/Commands/AdminListUserFriendshipsCommand.cs b/Govor.Console/Commands/AdminListUserFriendshipsCommand.cs new file mode 100644 index 0000000..3b81970 --- /dev/null +++ b/Govor.Console/Commands/AdminListUserFriendshipsCommand.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Json; +using System.Threading.Tasks; +using Govor.Contracts.DTOs; // Required for FriendshipDto + +namespace Govor.ConsoleClient.Commands +{ + public class AdminListUserFriendshipsCommand : BaseCommand + { + public override async Task ExecuteAsync(string? argument) + { + if (!EnsureLoggedIn()) return; + + Guid userId; + if (string.IsNullOrWhiteSpace(argument) || !Guid.TryParse(argument, out userId)) + { + Console.Write("Введите ID пользователя для просмотра его связей: "); + var input = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(input) || !Guid.TryParse(input, out userId)) + { + Console.WriteLine("[Ошибка] Неверный или пустой ID пользователя."); + return; + } + } + + Console.WriteLine($"Получение всех связей для пользователя {userId} (только для администраторов)..."); + try + { + var response = await HttpClientService.GetAsync($"api/admin/Friendships/{userId}"); + if (response.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + Console.WriteLine("[Ошибка] Доступ запрещен. Эта команда только для администраторов."); + return; + } + response.EnsureSuccessStatusCode(); + + var friendships = await response.Content.ReadFromJsonAsync>(); + + if (friendships != null && friendships.Any()) + { + Console.WriteLine($"Дружеские связи для пользователя {userId}:"); + foreach (var f in friendships) + { + Console.WriteLine($"- ID: {f.Id}, Пользователь1: {f.RequesterId}, Пользователь2: {f.AddresseeId}, Статус: {f.Status}"); + } + } + else + { + Console.WriteLine($"Дружеские связи для пользователя {userId} не найдены."); + } + } + catch (HttpRequestException httpEx) when (httpEx.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + Console.WriteLine("[Ошибка] Доступ запрещен. Эта команда только для администраторов."); + } + catch (Exception ex) + { + Console.WriteLine($"[Ошибка] {ex.Message}"); + } + } + + public override string GetHelp() + { + return "/adminlistuserfs [ID_пользователя] - (Админ) Показать все дружеские связи для указанного пользователя. Если ID не указан, запросит ввод."; + } + } +} diff --git a/Govor.Console/Commands/AdminRemoveFriendshipCommand.cs b/Govor.Console/Commands/AdminRemoveFriendshipCommand.cs new file mode 100644 index 0000000..07fdea2 --- /dev/null +++ b/Govor.Console/Commands/AdminRemoveFriendshipCommand.cs @@ -0,0 +1,64 @@ +using System; +using System.Net.Http; +using System.Threading.Tasks; + +namespace Govor.ConsoleClient.Commands +{ + public class AdminRemoveFriendshipCommand : BaseCommand + { + public override async Task ExecuteAsync(string? argument) + { + if (!EnsureLoggedIn()) return; + + Guid friendshipId; + if (string.IsNullOrWhiteSpace(argument) || !Guid.TryParse(argument, out friendshipId)) + { + Console.Write("Введите ID дружеской связи для удаления: "); + var input = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(input) || !Guid.TryParse(input, out friendshipId)) + { + Console.WriteLine("[Ошибка] Неверный или пустой ID связи."); + return; + } + } + + Console.WriteLine($"Удаление дружеской связи {friendshipId} (только для администраторов)..."); + try + { + // The API endpoint is: [HttpPost] public async Task RemoveFriendship(Guid Id) + // It expects the Id in the query string, like: api/admin/Friendships?Id=GUID + // Or it might be a POST with x-www-form-urlencoded data or JSON body, need to check API implementation. + // Based on `[HttpPost] public async Task RemoveFriendship(Guid Id)`, it's likely a query parameter or form data. + // Let's assume query parameter for now as it's simpler for HttpPost. + + var response = await HttpClientService.PostAsync($"api/admin/Friendships?Id={friendshipId}", null); + + if (response.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + Console.WriteLine("[Ошибка] Доступ запрещен. Эта команда только для администраторов."); + return; + } + response.EnsureSuccessStatusCode(); + + Console.WriteLine($"Дружеская связь {friendshipId} успешно удалена."); + } + catch (HttpRequestException httpEx) when (httpEx.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + Console.WriteLine("[Ошибка] Доступ запрещен. Эта команда только для администраторов."); + } + catch (HttpRequestException httpEx) when (httpEx.StatusCode == System.Net.HttpStatusCode.NotFound) + { + Console.WriteLine($"[Ошибка] Дружеская связь с ID {friendshipId} не найдена."); + } + catch (Exception ex) + { + Console.WriteLine($"[Ошибка] {ex.Message}"); + } + } + + public override string GetHelp() + { + return "/adminremovefs [ID_связи] - (Админ) Удалить дружескую связь по ее ID. Если ID не указан, запросит ввод."; + } + } +} diff --git a/Govor.Console/Commands/BaseCommand.cs b/Govor.Console/Commands/BaseCommand.cs new file mode 100644 index 0000000..358b48d --- /dev/null +++ b/Govor.Console/Commands/BaseCommand.cs @@ -0,0 +1,65 @@ +using System; +using System.Threading.Tasks; + +namespace Govor.ConsoleClient.Commands +{ + public abstract class BaseCommand : ICommand + { + protected static FriendsClient? FriendsClient { get; private set; } + protected static HttpClientService HttpClientService { get; private set; } + protected static Func GetAuthToken { get; private set; } + protected static Action SetAuthToken { get; private set; } + protected static Func InitializeHubConnectionAsync { get; private set; } + protected static Microsoft.AspNetCore.SignalR.Client.HubConnection? HubConnection => _hubConnection?.Invoke(); + private static Func _hubConnection; + + + public static void InitializeServices( + FriendsClient? friendsClient, + HttpClientService httpClientService, + Func getAuthToken, + Action setAuthToken, + Func initializeHubConnectionAsync, + Func hubConnection) + { + FriendsClient = friendsClient; + HttpClientService = httpClientService; + GetAuthToken = getAuthToken; + SetAuthToken = setAuthToken; + InitializeHubConnectionAsync = initializeHubConnectionAsync; + _hubConnection = hubConnection; + } + + public abstract Task ExecuteAsync(string? argument); + public abstract string GetHelp(); + + protected boolEnsureLoggedIn() + { + if (GetAuthToken() == null) + { + System.Console.WriteLine("[Ошибка] Сначала войдите в систему. Используйте /login или /reg."); + return false; + } + if (FriendsClient == null && !(this is LoginCommand || this is RegisterCommand)) // FriendsClient might not be needed for auth commands + { + // Attempt to re-initialize FriendsClient if token exists but client is null + // This can happen if the app was closed and token was persisted, but client was not re-instantiated. + // However, the current setup in Program.cs initializes FriendsClient after login/reg. + // This check is more of a safeguard. + System.Console.WriteLine("[Ошибка] Клиент для работы с друзьями не инициализирован. Попробуйте войти снова."); + return false; + } + return true; + } + + protected boolEnsureHubConnection() + { + if (HubConnection == null || HubConnection.State != Microsoft.AspNetCore.SignalR.Client.HubConnectionState.Connected) + { + System.Console.WriteLine("[Ошибка] SignalR соединение не установлено. Попробуйте войти снова или проверьте соединение."); + return false; + } + return true; + } + } +} diff --git a/Govor.Console/Commands/BlockUserCommand.cs b/Govor.Console/Commands/BlockUserCommand.cs new file mode 100644 index 0000000..766bef5 --- /dev/null +++ b/Govor.Console/Commands/BlockUserCommand.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading.Tasks; + +namespace Govor.ConsoleClient.Commands +{ + public class BlockUserCommand : BaseCommand + { + public override async Task ExecuteAsync(string? argument) + { + if (!EnsureLoggedIn() || !EnsureHubConnection()) return; + + Guid targetUserId; + if (string.IsNullOrWhiteSpace(argument) || !Guid.TryParse(argument, out targetUserId)) + { + Console.Write("Введите ID пользователя, которого хотите заблокировать: "); + var input = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(input) || !Guid.TryParse(input, out targetUserId)) + { + Console.WriteLine("[Ошибка] Неверный или пустой ID пользователя."); + return; + } + } + + try + { + // API uses Hub for this: BlockUser(Guid targetUserId) + // Located in Govor.API/Hubs/FriendsHub.cs + await HubConnection.InvokeAsync("BlockUser", targetUserId); + Console.WriteLine($"Пользователь {targetUserId} заблокирован."); + } + catch (Exception ex) + { + Console.WriteLine($"[Ошибка блокировки пользователя] {ex.Message}"); + } + } + + public override string GetHelp() + { + return "/block [ID_пользователя] - Заблокировать пользователя. Если ID не указан, запросит ввод."; + } + } +} diff --git a/Govor.Console/Commands/HelpCommand.cs b/Govor.Console/Commands/HelpCommand.cs new file mode 100644 index 0000000..c331a83 --- /dev/null +++ b/Govor.Console/Commands/HelpCommand.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Govor.ConsoleClient.Commands +{ + public class HelpCommand : BaseCommand + { + private readonly Dictionary _availableCommands; + + // The HelpCommand needs access to the list of all commands to display their help messages. + // This will be injected from Program.cs where the commands are registered. + public HelpCommand(Dictionary availableCommands) + { + _availableCommands = availableCommands; + } + + public override Task ExecuteAsync(string? argument) + { + Console.WriteLine("Доступные команды:"); + if (!string.IsNullOrWhiteSpace(argument)) + { + string commandKey = argument.StartsWith("/") ? argument : "/" + argument; + if (_availableCommands.TryGetValue(commandKey.ToLower(), out var specificCommand)) + { + Console.WriteLine(specificCommand.GetHelp()); + } + else + { + Console.WriteLine($"Команда '{argument}' не найдена. Введите /help для списка всех команд."); + } + } + else + { + foreach (var commandEntry in _availableCommands.OrderBy(c => c.Key)) + { + Console.WriteLine(commandEntry.Value.GetHelp()); + } + } + return Task.CompletedTask; + } + + public override string GetHelp() + { + return "/help [команда] - Показать список всех команд или помощь по конкретной команде."; + } + } +} diff --git a/Govor.Console/Commands/ICommand.cs b/Govor.Console/Commands/ICommand.cs new file mode 100644 index 0000000..0e3c8ae --- /dev/null +++ b/Govor.Console/Commands/ICommand.cs @@ -0,0 +1,8 @@ +namespace Govor.ConsoleClient.Commands +{ + public interface ICommand + { + Task ExecuteAsync(string? argument); + string GetHelp(); + } +} diff --git a/Govor.Console/Commands/ListFriendsCommand.cs b/Govor.Console/Commands/ListFriendsCommand.cs new file mode 100644 index 0000000..1d4cf92 --- /dev/null +++ b/Govor.Console/Commands/ListFriendsCommand.cs @@ -0,0 +1,40 @@ +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Govor.ConsoleClient.Commands +{ + public class ListFriendsCommand : BaseCommand + { + public override async Task ExecuteAsync(string? argument) + { + if (!EnsureLoggedIn()) return; + + try + { + var friends = await FriendsClient.GetFriendsAsync(); + if (friends.Any()) + { + Console.WriteLine("Ваши друзья:"); + foreach (var f in friends) + { + Console.WriteLine($"- {f.Username} | был онлайн: {f.WasOnline} [{f.Id}]"); + } + } + else + { + Console.WriteLine("У вас пока нет друзей."); + } + } + catch (Exception ex) + { + Console.WriteLine($"[Ошибка] {ex.Message}"); + } + } + + public override string GetHelp() + { + return "/friends - Показать список ваших друзей."; + } + } +} diff --git a/Govor.Console/Commands/ListIncomingRequestsCommand.cs b/Govor.Console/Commands/ListIncomingRequestsCommand.cs new file mode 100644 index 0000000..3bfa6a7 --- /dev/null +++ b/Govor.Console/Commands/ListIncomingRequestsCommand.cs @@ -0,0 +1,44 @@ +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Govor.ConsoleClient.Commands +{ + public class ListIncomingRequestsCommand : BaseCommand + { + public override async Task ExecuteAsync(string? argument) + { + if (!EnsureLoggedIn()) return; + + try + { + // This still uses REST client as per existing FriendsClient + var requests = await FriendsClient.GetIncomingRequestsAsync(); + if (requests.Any()) + { + Console.WriteLine("Входящие заявки в друзья:"); + foreach (var r in requests) + { + // Assuming you want to show who sent the request. + // The FriendshipDto contains RequesterId and AddresseeId. + // If current user is AddresseeId, then RequesterId is the one who sent it. + Console.WriteLine($"- Запрос ID: {r.Id}. От пользователя ID: {r.RequesterId}. Статус: {r.Status}. (принять через /accept {r.Id})"); + } + } + else + { + Console.WriteLine("У вас нет входящих заявок в друзья."); + } + } + catch (Exception ex) + { + Console.WriteLine($"[Ошибка] {ex.Message}"); + } + } + + public override string GetHelp() + { + return "/incoming - Показать список входящих заявок в друзья."; + } + } +} diff --git a/Govor.Console/Commands/ListOutgoingRequestsCommand.cs b/Govor.Console/Commands/ListOutgoingRequestsCommand.cs new file mode 100644 index 0000000..992dd60 --- /dev/null +++ b/Govor.Console/Commands/ListOutgoingRequestsCommand.cs @@ -0,0 +1,50 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Json; +using System.Threading.Tasks; +using Govor.Contracts.DTOs; // Required for FriendshipDto + +namespace Govor.ConsoleClient.Commands +{ + public class ListOutgoingRequestsCommand : BaseCommand + { + public override async Task ExecuteAsync(string? argument) + { + if (!EnsureLoggedIn()) return; + + try + { + // This functionality maps to GET api/friends/responses in FriendsRequestQueryController + // FriendsClient doesn't have a dedicated method, so we use HttpClientService or direct HttpClient + var response = await HttpClientService.GetAsync("api/friends/responses"); + response.EnsureSuccessStatusCode(); + + var requests = await response.Content.ReadFromJsonAsync>(); + + if (requests != null && requests.Any()) + { + Console.WriteLine("Отправленные вами заявки в друзья (ожидают ответа):"); + foreach (var r in requests) + { + // If current user is RequesterId, then AddresseeId is the one they sent to. + Console.WriteLine($"- Запрос ID: {r.Id}. Пользователю ID: {r.AddresseeId}. Статус: {r.Status}."); + } + } + else + { + Console.WriteLine("У вас нет отправленных заявок, ожидающих ответа."); + } + } + catch (Exception ex) + { + Console.WriteLine($"[Ошибка] {ex.Message}"); + } + } + + public override string GetHelp() + { + return "/outgoing - Показать список отправленных вами заявок в друзья, ожидающих ответа."; + } + } +} diff --git a/Govor.Console/Commands/LoginCommand.cs b/Govor.Console/Commands/LoginCommand.cs new file mode 100644 index 0000000..94b8665 --- /dev/null +++ b/Govor.Console/Commands/LoginCommand.cs @@ -0,0 +1,50 @@ +using System; +using System.Net.Http.Headers; +using System.Threading.Tasks; + +namespace Govor.ConsoleClient.Commands +{ + public class LoginCommand : BaseCommand + { + public override async Task ExecuteAsync(string? argument) + { + Console.Write("username: "); + var loginUsername = Console.ReadLine(); + + Console.Write("password: "); + var loginPassword = Console.ReadLine(); + + if (string.IsNullOrWhiteSpace(loginUsername) || string.IsNullOrWhiteSpace(loginPassword)) + { + Console.WriteLine("[Ошибка] Имя пользователя и пароль не могут быть пустыми."); + return; + } + + try + { + var authToken = await HttpClientService.LoginAsync(loginUsername, loginPassword); + SetAuthToken(authToken); + + // Initialize FriendsClient after successful login + var sharedClient = new HttpClient { BaseAddress = new Uri(HttpClientService.GetBaseUrl()) }; + sharedClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", authToken); + var friendsClient = new FriendsClient(sharedClient); + + // Re-initialize services in BaseCommand with the new FriendsClient + InitializeServices(friendsClient, HttpClientService, GetAuthToken, SetAuthToken, InitializeHubConnectionAsync, HubConnection); + + await InitializeHubConnectionAsync(); + Console.WriteLine("[Успех] Вход выполнен. Токен сохранен."); + } + catch (Exception ex) + { + Console.WriteLine($"[Ошибка] {ex.Message}"); + } + } + + public override string GetHelp() + { + return "/login - Войти в существующий аккаунт."; + } + } +} diff --git a/Govor.Console/Commands/RegisterCommand.cs b/Govor.Console/Commands/RegisterCommand.cs new file mode 100644 index 0000000..c1bcfc0 --- /dev/null +++ b/Govor.Console/Commands/RegisterCommand.cs @@ -0,0 +1,53 @@ +using System; +using System.Net.Http.Headers; +using System.Threading.Tasks; + +namespace Govor.ConsoleClient.Commands +{ + public class RegisterCommand : BaseCommand + { + public override async Task ExecuteAsync(string? argument) + { + Console.Write("username: "); + var regUsername = Console.ReadLine(); + + Console.Write("password: "); + var regPassword = Console.ReadLine(); + + Console.Write("invitation code: "); + var inviteCode = Console.ReadLine(); + + if (string.IsNullOrWhiteSpace(regUsername) || string.IsNullOrWhiteSpace(regPassword) || string.IsNullOrWhiteSpace(inviteCode)) + { + Console.WriteLine("[Ошибка] Имя пользователя, пароль и код приглашения не могут быть пустыми."); + return; + } + + try + { + var authToken = await HttpClientService.RegisterAsync(regUsername, regPassword, inviteCode); + SetAuthToken(authToken); + + // Initialize FriendsClient after successful registration + var sharedClient = new HttpClient { BaseAddress = new Uri(HttpClientService.GetBaseUrl()) }; + sharedClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", authToken); + var friendsClient = new FriendsClient(sharedClient); + + // Re-initialize services in BaseCommand with the new FriendsClient + InitializeServices(friendsClient, HttpClientService, GetAuthToken, SetAuthToken, InitializeHubConnectionAsync, HubConnection); + + await InitializeHubConnectionAsync(); + Console.WriteLine("[Успех] Регистрация завершена. Токен сохранен."); + } + catch (Exception ex) + { + Console.WriteLine($"[Ошибка] {ex.Message}"); + } + } + + public override string GetHelp() + { + return "/reg - Зарегистрировать новый аккаунт с помощью кода приглашения."; + } + } +} diff --git a/Govor.Console/Commands/RejectFriendRequestCommand.cs b/Govor.Console/Commands/RejectFriendRequestCommand.cs new file mode 100644 index 0000000..f2a0f83 --- /dev/null +++ b/Govor.Console/Commands/RejectFriendRequestCommand.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading.Tasks; + +namespace Govor.ConsoleClient.Commands +{ + public class RejectFriendRequestCommand : BaseCommand + { + public override async Task ExecuteAsync(string? argument) + { + if (!EnsureLoggedIn() || !EnsureHubConnection()) return; + + Guid friendshipId; + if (string.IsNullOrWhiteSpace(argument) || !Guid.TryParse(argument, out friendshipId)) + { + Console.Write("Введите ID заявки, которую хотите отклонить: "); + var input = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(input) || !Guid.TryParse(input, out friendshipId)) + { + Console.WriteLine("[Ошибка] Неверный или пустой ID заявки."); + return; + } + } + + try + { + // API uses Hub for this: RejectFriendRequest(Guid friendshipId) + // Located in Govor.API/Hubs/FriendsHub.cs + await HubConnection.InvokeAsync("RejectFriendRequest", friendshipId); + Console.WriteLine("Заявка в друзья отклонена."); + } + catch (Exception ex) + { + Console.WriteLine($"[Ошибка отклонения заявки] {ex.Message}"); + } + } + + public override string GetHelp() + { + return "/reject [ID_заявки] - Отклонить входящую заявку в друзья. Если ID не указан, запросит ввод."; + } + } +} diff --git a/Govor.Console/Commands/SearchUserCommand.cs b/Govor.Console/Commands/SearchUserCommand.cs new file mode 100644 index 0000000..1c00d31 --- /dev/null +++ b/Govor.Console/Commands/SearchUserCommand.cs @@ -0,0 +1,51 @@ +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Govor.ConsoleClient.Commands +{ + public class SearchUserCommand : BaseCommand + { + public override async Task ExecuteAsync(string? argument) + { + if (!EnsureLoggedIn()) return; + + if (string.IsNullOrWhiteSpace(argument)) + { + Console.Write("Введите имя пользователя для поиска: "); + argument = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(argument)) + { + Console.WriteLine("[Ошибка] Поисковый запрос не может быть пустым."); + return; + } + } + + try + { + var foundUsers = await FriendsClient.SearchAsync(argument); + if (foundUsers.Any()) + { + Console.WriteLine("Найденные пользователи:"); + foreach (var user in foundUsers) + { + Console.WriteLine($"- {user.Username} [{user.Id}]"); + } + } + else + { + Console.WriteLine("Пользователи не найдены."); + } + } + catch (Exception ex) + { + Console.WriteLine($"[Ошибка] {ex.Message}"); + } + } + + public override string GetHelp() + { + return "/search [имя_пользователя] - Найти пользователей по имени. Если имя не указано, запросит ввод."; + } + } +} diff --git a/Govor.Console/Commands/SendFriendRequestCommand.cs b/Govor.Console/Commands/SendFriendRequestCommand.cs new file mode 100644 index 0000000..62aae31 --- /dev/null +++ b/Govor.Console/Commands/SendFriendRequestCommand.cs @@ -0,0 +1,41 @@ +using System; +using System.Threading.Tasks; + +namespace Govor.ConsoleClient.Commands +{ + public class SendFriendRequestCommand : BaseCommand + { + public override async Task ExecuteAsync(string? argument) + { + if (!EnsureLoggedIn() || !EnsureHubConnection()) return; + + Guid targetUserId; + if (string.IsNullOrWhiteSpace(argument) || !Guid.TryParse(argument, out targetUserId)) + { + Console.Write("Введите ID пользователя, которому хотите отправить запрос: "); + var input = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(input) || !Guid.TryParse(input, out targetUserId)) + { + Console.WriteLine("[Ошибка] Неверный или пустой ID пользователя."); + return; + } + } + + try + { + // API uses Hub for this: SendFriendRequest(Guid targetUserId) + await HubConnection.InvokeAsync("SendFriendRequest", targetUserId); + Console.WriteLine("Запрос в друзья отправлен."); + } + catch (Exception ex) + { + Console.WriteLine($"[Ошибка отправки запроса] {ex.Message}"); + } + } + + public override string GetHelp() + { + return "/friend [ID_пользователя] - Отправить запрос в друзья пользователю с указанным ID. Если ID не указан, запросит ввод."; + } + } +} diff --git a/Govor.Console/Commands/UnblockUserCommand.cs b/Govor.Console/Commands/UnblockUserCommand.cs new file mode 100644 index 0000000..bc2dae4 --- /dev/null +++ b/Govor.Console/Commands/UnblockUserCommand.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading.Tasks; + +namespace Govor.ConsoleClient.Commands +{ + public class UnblockUserCommand : BaseCommand + { + public override async Task ExecuteAsync(string? argument) + { + if (!EnsureLoggedIn() || !EnsureHubConnection()) return; + + Guid targetUserId; + if (string.IsNullOrWhiteSpace(argument) || !Guid.TryParse(argument, out targetUserId)) + { + Console.Write("Введите ID пользователя, которого хотите разблокировать: "); + var input = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(input) || !Guid.TryParse(input, out targetUserId)) + { + Console.WriteLine("[Ошибка] Неверный или пустой ID пользователя."); + return; + } + } + + try + { + // API uses Hub for this: UnblockUser(Guid targetUserId) + // Located in Govor.API/Hubs/FriendsHub.cs + await HubConnection.InvokeAsync("UnblockUser", targetUserId); + Console.WriteLine($"Пользователь {targetUserId} разблокирован."); + } + catch (Exception ex) + { + Console.WriteLine($"[Ошибка разблокировки пользователя] {ex.Message}"); + } + } + + public override string GetHelp() + { + return "/unblock [ID_пользователя] - Разблокировать пользователя. Если ID не указан, запросит ввод."; + } + } +} diff --git a/Govor.Console/FriendsClient.cs b/Govor.Console/FriendsClient.cs index 21593f7..0af8398 100644 --- a/Govor.Console/FriendsClient.cs +++ b/Govor.Console/FriendsClient.cs @@ -1,5 +1,8 @@ using System.Net.Http.Json; using Govor.Contracts.DTOs; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; // Added for Task namespace Govor.ConsoleClient { @@ -20,11 +23,12 @@ namespace Govor.ConsoleClient return await response.Content.ReadFromJsonAsync>() ?? new List(); } - public async Task SendFriendRequestAsync(Guid targetUserId) - { - var response = await _client.PostAsync($"api/friends/request?targetUserId={targetUserId}", null); - response.EnsureSuccessStatusCode(); - } + // SendFriendRequestAsync was removed as the SendFriendRequestCommand now uses SignalR directly. + // public async Task SendFriendRequestAsync(Guid targetUserId) + // { + // var response = await _client.PostAsync($"api/friends/request?targetUserId={targetUserId}", null); + // response.EnsureSuccessStatusCode(); + // } public async Task> GetIncomingRequestsAsync() { @@ -34,11 +38,12 @@ namespace Govor.ConsoleClient return await response.Content.ReadFromJsonAsync>() ?? new List(); } - public async Task AcceptFriendRequestAsync(Guid requesterId) - { - var response = await _client.PostAsync($"/api/friends/accept?friendshipId={requesterId}", null); - response.EnsureSuccessStatusCode(); - } + // AcceptFriendRequestAsync was removed as the AcceptFriendRequestCommand now uses SignalR directly. + // public async Task AcceptFriendRequestAsync(Guid friendshipId) // Parameter was requesterId, should be friendshipId + // { + // var response = await _client.PostAsync($"/api/friends/accept?friendshipId={friendshipId}", null); + // response.EnsureSuccessStatusCode(); + // } public async Task> GetFriendsAsync() { @@ -49,4 +54,4 @@ namespace Govor.ConsoleClient } } -} +} \ No newline at end of file diff --git a/Govor.Console/Program.cs b/Govor.Console/Program.cs index 7bfbf4a..1c8760c 100644 --- a/Govor.Console/Program.cs +++ b/Govor.Console/Program.cs @@ -1,13 +1,14 @@ using Microsoft.AspNetCore.SignalR.Client; +using System; +using System.Collections.Generic; using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Net.Http; using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Text.Json; -using Govor.Contracts.Requests; +using System.Threading.Tasks; +using Govor.ConsoleClient.Commands; using Govor.Contracts.Requests.SignalR; using Govor.Contracts.Responses.SignalR; -using Govor.Core.Models; - /*==================================== *Личные сообщения| Егор @@ -29,14 +30,26 @@ namespace Govor.ConsoleClient static string baseUrl = "https://localhost:7155"; static string? AuthToken = null; static HttpClientService HttpService = new(baseUrl); - private static FriendsClient? friendsClient; + private static FriendsClient? _friendsClient; // Renamed to avoid conflict with BaseCommand static HubConnection? _hubConnection; static Dictionary> ChatHistory = new(); static string? CurrentChatUser = null; static Guid CurrentChatUserId = Guid.Empty; + private static Dictionary _commands = new(); + static async Task Main() { + InitializeCommands(); + BaseCommand.InitializeServices( + _friendsClient, // Initially null, will be set by Login/Register commands + HttpService, + () => AuthToken, + (token) => AuthToken = token, + InitializeHubConnection, + () => _hubConnection + ); + LoginWindow(); while (true) @@ -47,170 +60,121 @@ namespace Govor.ConsoleClient if (string.IsNullOrWhiteSpace(input)) continue; if (input.StartsWith("/")) - HandleCommand(input); + await HandleCommandAsync(input); else if (CurrentChatUser != null) - SendMessage(input); + await SendMessageAsync(input); else - Console.WriteLine("[Система] Введите команду, например /friends"); + Console.WriteLine("[Система] Введите команду, например /help или /friends"); } } - static async void HandleCommand(string input) + static void InitializeCommands() { - var args = input.Split(' ', 2); - var command = args[0].ToLower(); - var argument = args.Length > 1 ? args[1] : null; - - switch (command) + _commands = new Dictionary { - case "/login": - Console.Write("username: "); - var loginUsername = Console.ReadLine(); - - Console.Write("password: "); - var loginPassword = Console.ReadLine(); - - try - { - AuthToken = await HttpService.LoginAsync(loginUsername, loginPassword); - HttpClient sharedClient = new(); - sharedClient.BaseAddress = new Uri(baseUrl); - sharedClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", AuthToken); - - friendsClient = new FriendsClient(sharedClient); - await InitializeHubConnection(); - Console.WriteLine("[Успех] Вход выполнен. Токен сохранен."); - } - catch (Exception ex) - { - Console.WriteLine($"[Ошибка] {ex.Message}"); - } - break; - case "/reg": - Console.Write("username: "); - var regUsername = Console.ReadLine(); - - Console.Write("password: "); - var regPassword = Console.ReadLine(); - - Console.Write("invitation: "); - var inviteCode = Console.ReadLine(); - - try - { - AuthToken = await HttpService.RegisterAsync(regUsername, regPassword, inviteCode); - HttpClient sharedClient = new(); - sharedClient.BaseAddress = new Uri(baseUrl); - sharedClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", AuthToken); - - friendsClient = new FriendsClient(sharedClient); - await InitializeHubConnection(); - Console.WriteLine("[Успех] Регистрация завершена. Токен сохранен."); - } - catch (Exception ex) - { - Console.WriteLine($"[Ошибка] {ex.Message}"); - } - break; - case "/search": - if (friendsClient == null) - { - Console.WriteLine("[Ошибка] Сначала войдите в систему."); - break; - } - Console.Write("Введите имя друга: "); - var q = Console.ReadLine(); - var foundUsers = await friendsClient.SearchAsync(q); - foreach (var user in foundUsers) - { - Console.WriteLine($"{user.Username} [{user.Id}]"); - } - break; + { "/login", new LoginCommand() }, + { "/reg", new RegisterCommand() }, + { "/search", new SearchUserCommand() }, + { "/friends", new ListFriendsCommand() }, + { "/friend", new SendFriendRequestCommand() }, // Alias for SendFriendRequest + { "/sendrequest", new SendFriendRequestCommand() }, + { "/accept", new AcceptFriendRequestCommand() }, + { "/reject", new RejectFriendRequestCommand() }, + { "/incoming", new ListIncomingRequestsCommand() }, + { "/outgoing", new ListOutgoingRequestsCommand() }, + { "/block", new BlockUserCommand() }, + { "/unblock", new UnblockUserCommand() }, - case "/exit": - Console.WriteLine("[Система] Выход..."); - if (_hubConnection != null) - { - await _hubConnection.StopAsync(); - await _hubConnection.DisposeAsync(); - } - Environment.Exit(0); - break; + { "/adminlistallfs", new AdminListAllFriendshipsCommand() }, + { "/adminlistuserfs", new AdminListUserFriendshipsCommand() }, + { "/adminremovefs", new AdminRemoveFriendshipCommand() }, + // Help command is special as it needs the list of commands + // It will be added after other commands are initialized + }; + _commands.Add("/help", new HelpCommand(_commands)); + _commands.Add("-h", new HelpCommand(_commands)); // Alias for help - case "/friends": - if (friendsClient == null) - { - Console.WriteLine("[Ошибка] Сначала войдите в систему."); - break; - } - var friends = await friendsClient.GetFriendsAsync(); - foreach (var f in friends) - { - Console.WriteLine($"{f.Username} | был онлайн: {f.WasOnline} [{f.Id}]"); - } - break; - case "/friend": - if (friendsClient == null) - { - Console.WriteLine("[Ошибка] Сначала войдите в систему."); - break; - } - Console.Write("Введите ID пользователя, которому хотите отправить запрос: "); - var targetId = Guid.Parse(Console.ReadLine()); - await friendsClient.SendFriendRequestAsync(targetId); - Console.WriteLine("Запрос отправлен"); - break; + // Add other general commands not refactored yet if any, or create classes for them + // For example, /exit, /chat, /back will be handled separately for now or made into commands + } - case "/accept": - if (friendsClient == null) - { - Console.WriteLine("[Ошибка] Сначала войдите в систему."); - break; - } - Console.Write("Введите ID заявки, которую хотите принять принимаете: "); - var acceptId = Guid.Parse(Console.ReadLine()); - await friendsClient.AcceptFriendRequestAsync(acceptId); - Console.WriteLine("Принято"); - break; - case "/incoming": - if (friendsClient == null) - { - Console.WriteLine("[Ошибка] Сначала войдите в систему."); - break; - } - var requests = await friendsClient.GetIncomingRequestsAsync(); - foreach (var r in requests) - { - Console.WriteLine($"Запрос от: {r.RequesterId} (добавьте через /accept {r.Id})"); - } - break; - case "/chat": - if (string.IsNullOrEmpty(argument)) - { - Console.WriteLine("[Ошибка] Укажите имя друга и ID через пробел (например /chat Egor GUID)"); - break; - } + // Public static method to allow commands (like Login/Register) to update the FriendsClient + public static void UpdateFriendsClient(FriendsClient newFriendsClient) + { + _friendsClient = newFriendsClient; + // Re-initialize services in BaseCommand with the new FriendsClient + BaseCommand.InitializeServices( + _friendsClient, + HttpService, + () => AuthToken, + (token) => AuthToken = token, + InitializeHubConnection, + () => _hubConnection + ); + } - var chatArgs = argument.Split(' ', 2); - if (chatArgs.Length < 2 || !Guid.TryParse(chatArgs[1], out var chatUserId)) - { - Console.WriteLine("[Ошибка] Неверный формат. Укажите имя друга и ID (например /chat Egor GUID)"); + + static async Task HandleCommandAsync(string input) + { + var parts = input.Split(new[] { ' ' }, 2, StringSplitOptions.RemoveEmptyEntries); + var commandKey = parts[0].ToLower(); + var argument = parts.Length > 1 ? parts[1] : null; + + if (_commands.TryGetValue(commandKey, out var commandInstance)) + { + try + { + await commandInstance.ExecuteAsync(argument); + } + catch (Exception ex) + { + Console.WriteLine($"[Критическая ошибка выполнения команды {commandKey}]: {ex.Message}"); + // Log detailed exception: Console.WriteLine(ex.ToString()); + } + } + else + { + // Handle non-refactored commands for now + switch (commandKey) + { + case "/exit": + Console.WriteLine("[Система] Выход..."); + if (_hubConnection != null) + { + await _hubConnection.StopAsync(); + await _hubConnection.DisposeAsync(); + } + Environment.Exit(0); break; - } + case "/chat": + if (string.IsNullOrEmpty(argument)) + { + Console.WriteLine("[Ошибка] Укажите имя друга и ID через пробел (например /chat Egor GUID)"); + break; + } - CurrentChatUser = chatArgs[0]; - CurrentChatUserId = chatUserId; - ShowChat(CurrentChatUser); - break; + var chatArgs = argument.Split(' ', 2); + if (chatArgs.Length < 2 || !Guid.TryParse(chatArgs[1], out var chatUserId)) + { + Console.WriteLine("[Ошибка] Неверный формат. Укажите имя друга и ID (например /chat Egor GUID)"); + break; + } - case "/back": - CurrentChatUser = null; - CurrentChatUserId = Guid.Empty; - break; + CurrentChatUser = chatArgs[0]; + CurrentChatUserId = chatUserId; + ShowChat(CurrentChatUser); + break; - default: - Console.WriteLine("[Ошибка] Неизвестная команда"); - break; + case "/back": + CurrentChatUser = null; + CurrentChatUserId = Guid.Empty; + Console.Clear(); // Optionally clear console when going back + LoginWindow(); // Or some other general view + break; + default: + Console.WriteLine($"[Ошибка] Неизвестная команда: {commandKey}. Введите /help для списка команд."); + break; + } } } @@ -218,10 +182,17 @@ namespace Govor.ConsoleClient { if (string.IsNullOrEmpty(AuthToken)) { + // This check might be redundant if commands calling this ensure login first Console.WriteLine("[Ошибка] Токен аутентификации отсутствует. Пожалуйста, войдите в систему."); return; } + if (_hubConnection != null && _hubConnection.State != HubConnectionState.Disconnected) + { + Console.WriteLine("[SignalR] Соединение уже установлено или устанавливается."); + return; + } + _hubConnection = new HubConnectionBuilder() .WithUrl($"{baseUrl}/api/chats", options => { @@ -231,40 +202,76 @@ namespace Govor.ConsoleClient _hubConnection.On("ReceiveMessage", (message) => { - var senderName = message.SenderId == GetMyUserId() ? "Ты" : CurrentChatUser ?? "Неизвестно"; // Simplified for now + var myId = GetMyUserId(); + var senderName = message.SenderId == myId ? "Ты" : CurrentChatUser ?? message.SenderUsername ?? "Неизвестно"; var displayMessage = $"[{message.SentAt:HH:mm}] {senderName} >> {message.EncryptedContent}"; - string chatKey = message.SenderId == GetMyUserId() ? message.RecipientId.ToString() : message.SenderId.ToString(); + // Determine chat key based on sender/recipient, ensuring consistency + string chatKey = message.SenderId == myId ? message.RecipientId.ToString() : message.SenderId.ToString(); + string activeChatDisplayUser = CurrentChatUser; // User whose chat window is open - if (CurrentChatUser != null && (message.SenderId == CurrentChatUserId || message.RecipientId == CurrentChatUserId)) + // If the message belongs to the currently active chat window + if (CurrentChatUser != null && (message.SenderId == CurrentChatUserId || (message.RecipientId == CurrentChatUserId && message.SenderId == myId))) { - if (!ChatHistory.ContainsKey(CurrentChatUser)) + if (!ChatHistory.ContainsKey(activeChatDisplayUser)) { - ChatHistory[CurrentChatUser] = new List(); + ChatHistory[activeChatDisplayUser] = new List(); } - ChatHistory[CurrentChatUser].Add(displayMessage); - Console.SetCursorPosition(0, Console.CursorTop); // Move cursor to beginning of line - Console.Write(new string(' ', Console.WindowWidth -1)); // Clear current line - Console.SetCursorPosition(0, Console.CursorTop); - Console.WriteLine($" * {displayMessage}"); // Display new message - Console.Write(">> "); // Re-display prompt + ChatHistory[activeChatDisplayUser].Add(displayMessage); + + // Smartly update console without disrupting user input too much + int originalCursorTop = Console.CursorTop; + int originalCursorLeft = Console.CursorLeft; + Console.MoveBufferArea(0, originalCursorTop, Console.BufferWidth, 1, 0, originalCursorTop +1); // scroll current line down + Console.SetCursorPosition(0, originalCursorTop); + Console.WriteLine($" * {displayMessage}"); + Console.SetCursorPosition(originalCursorLeft, originalCursorTop +1); // +1 because writeline added a line + Console.Write(">> "); // Re-display prompt part } - else + else // Notification for message not in the current chat { - // Handle notification for messages not in the current chat - TBD - Console.WriteLine($"\n[Новое сообщение от {message.SenderId}]: {message.EncryptedContent}"); - Console.Write(CurrentChatUser == null ? "/> " : ">> "); + Console.WriteLine($"\n[Новое сообщение от {message.SenderUsername ?? message.SenderId.ToString()}]: {message.EncryptedContent}"); + Console.Write(CurrentChatUser == null ? "/> " : ">> "); // Re-display prompt } }); _hubConnection.On("MessageSent", (message) => { // Optional: Handle confirmation that message was sent successfully by the server - // This could be useful for updating UI to show message status (e.g., "sent", "delivered") - // For console, just logging or ignoring might be fine. - // Console.WriteLine($"[Система] Сообщение {message.MessageId} доставлено на сервер."); + // For console, maybe a subtle notification or log if verbose mode is on. + // Example: Console.WriteLine($"[Система] Сообщение для {message.RecipientUsername} доставлено на сервер."); }); + _hubConnection.On("FriendRequestReceived", (message) => // Assuming message is a simple string notification + { + Console.WriteLine($"\n[Система] Новый запрос в друзья: {message}"); + Console.Write(CurrentChatUser == null ? "/> " : ">> "); + }); + + _hubConnection.On("FriendRequestAccepted", (message) => + { + Console.WriteLine($"\n[Система] Ваш запрос в друзья принят: {message}"); + Console.Write(CurrentChatUser == null ? "/> " : ">> "); + }); + + _hubConnection.On("FriendRemoved", (message) => + { + Console.WriteLine($"\n[Система] Пользователь удалил вас из друзей или вы удалили его: {message}"); + Console.Write(CurrentChatUser == null ? "/> " : ">> "); + }); + + _hubConnection.On("UserBlocked", (message) => + { + Console.WriteLine($"\n[Система] Пользователь заблокирован: {message}"); + Console.Write(CurrentChatUser == null ? "/> " : ">> "); + }); + _hubConnection.On("UserUnblocked", (message) => + { + Console.WriteLine($"\n[Система] Пользователь разблокирован: {message}"); + Console.Write(CurrentChatUser == null ? "/> " : ">> "); + }); + + try { await _hubConnection.StartAsync(); @@ -280,8 +287,9 @@ namespace Govor.ConsoleClient { if (AuthToken == null) return Guid.Empty; var handler = new JwtSecurityTokenHandler(); + if (!handler.CanReadToken(AuthToken)) return Guid.Empty; var jwtToken = handler.ReadJwtToken(AuthToken); - var userIdClaim = jwtToken.Claims.FirstOrDefault(claim => claim.Type == "userId"); + var userIdClaim = jwtToken.Claims.FirstOrDefault(claim => claim.Type == "userId" || claim.Type == System.Security.Claims.ClaimTypes.NameIdentifier); return userIdClaim != null && Guid.TryParse(userIdClaim.Value, out var userId) ? userId : Guid.Empty; } @@ -291,22 +299,24 @@ namespace Govor.ConsoleClient Console.Clear(); Console.WriteLine($"/*===================================="); Console.WriteLine($" * Личные сообщения | {user} ({CurrentChatUserId})"); + Console.WriteLine($" * (/back для выхода из чата)"); Console.WriteLine($" *===================================="); if (!ChatHistory.ContainsKey(user)) { ChatHistory[user] = new List(); - // Example initial messages - consider fetching history if implementing that - // ChatHistory[user].Add($"[Система] Начало чата с {user}"); + // Potentially load chat history here via an API call if desired + // ChatHistory[user].Add($"[Система] Начало чата с {user}. Введите сообщение или /back."); + } + else + { + foreach (var msg in ChatHistory[user]) + Console.WriteLine(" * " + msg); } - - foreach (var msg in ChatHistory[user]) - Console.WriteLine(" * " + msg); - Console.WriteLine(" *------------------------------------"); } - static async void SendMessage(string messageContent) + static async Task SendMessageAsync(string messageContent) { if (string.IsNullOrWhiteSpace(messageContent) || _hubConnection == null || CurrentChatUserId == Guid.Empty) return; @@ -320,24 +330,23 @@ namespace Govor.ConsoleClient var messageRequest = new MessageRequest { RecipientId = CurrentChatUserId, - EncryptedContent = messageContent, // Assuming content is not actually encrypted for console client simplicity for now + EncryptedContent = messageContent, RecipientType = RecipientType.User, - // MediaAttachments and ReplyToMessageId can be added if needed }; try { await _hubConnection.InvokeAsync("Send", messageRequest); - // Add to local history immediately for responsiveness. - // Server will send "ReceiveMessage" to self as well if needed, or "MessageSent" for confirmation. var displayMessage = $"[{DateTime.Now:HH:mm}] Ты >> {messageContent}"; if (!ChatHistory.ContainsKey(CurrentChatUser!)) { ChatHistory[CurrentChatUser!] = new List(); } ChatHistory[CurrentChatUser!].Add(displayMessage); - // Console.WriteLine($"Ты >> {messageContent}"); // Already handled by input echo + ReceiveMessage from self + // No Console.WriteLine here, message will be echoed by "ReceiveMessage" if server sends it to self, + // or we rely on user seeing their own typed message. + // The ReceiveMessage handler should ideally handle self-messages for consistent display. } catch (Exception ex) { @@ -352,12 +361,14 @@ namespace Govor.ConsoleClient Console.WriteLine($" *===================================="); Console.WriteLine(" * /reg - создать аккаунт "); Console.WriteLine(" * /login - войти"); + Console.WriteLine(" * /help - список команд"); } } } - -public class LoginResponse -{ - public string token { get; set; } -} \ No newline at end of file +// LoginResponse class was here, can be removed if not used elsewhere or moved to a Contracts project. +// Assuming it's implicitly handled by HttpClientService or was specific to the old Program.cs structure. +// public class LoginResponse +// { +// public string token { get; set; } +// } \ No newline at end of file From 9d06984e8c52e3ef96b992beb2c4b8db26c8a162 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Fri, 11 Jul 2025 15:52:10 +0700 Subject: [PATCH 043/101] rework console client --- Govor.API.Tests/Govor.API.Tests.csproj | 4 - Govor.API/Hubs/ChatsHub.cs | 71 +++++++- Govor.API/Program.cs | 4 +- .../Commands/AcceptFriendRequestCommand.cs | 3 +- .../AdminListAllFriendshipsCommand.cs | 14 +- .../AdminListUserFriendshipsCommand.cs | 9 +- .../Commands/AdminRemoveFriendshipCommand.cs | 9 +- Govor.Console/Commands/BaseCommand.cs | 42 ++--- Govor.Console/Commands/BlockUserCommand.cs | 1 + .../Commands/ListOutgoingRequestsCommand.cs | 9 +- Govor.Console/Commands/LoginCommand.cs | 5 +- Govor.Console/Commands/RegisterCommand.cs | 2 +- .../Commands/RejectFriendRequestCommand.cs | 3 +- .../Commands/SendFriendRequestCommand.cs | 3 +- Govor.Console/Commands/UnblockUserCommand.cs | 1 + Govor.Console/FriendsHubClient.cs | 157 ++++++++++++++++++ Govor.Console/HttpClientService.cs | 22 +++ Govor.Console/Program.cs | 9 +- .../SignalR/MessageRemovedResponse.cs | 11 ++ 19 files changed, 312 insertions(+), 67 deletions(-) create mode 100644 Govor.Console/FriendsHubClient.cs create mode 100644 Govor.Contracts/Responses/SignalR/MessageRemovedResponse.cs diff --git a/Govor.API.Tests/Govor.API.Tests.csproj b/Govor.API.Tests/Govor.API.Tests.csproj index b74c1ac..2ef9646 100644 --- a/Govor.API.Tests/Govor.API.Tests.csproj +++ b/Govor.API.Tests/Govor.API.Tests.csproj @@ -27,8 +27,4 @@ - - - - diff --git a/Govor.API/Hubs/ChatsHub.cs b/Govor.API/Hubs/ChatsHub.cs index f0f509c..af8c6e3 100644 --- a/Govor.API/Hubs/ChatsHub.cs +++ b/Govor.API/Hubs/ChatsHub.cs @@ -87,15 +87,15 @@ public class ChatsHub : Hub public async Task Send(MessageRequest request) { + var senderId = GetUserId(); + if (string.IsNullOrWhiteSpace(request.EncryptedContent) && (request.MediaAttachments == null || !request.MediaAttachments.Any())) { - _logger.LogWarning("Empty message (no content and no attachments) received from user {UserId}", - GetUserId()); + _logger.LogWarning("Empty message (no content and no attachments) received from user {UserId}", senderId); throw new ArgumentException("Message cannot be empty (must have content or attachments)."); } - var senderId = GetUserId(); _logger.LogInformation("Message send initiated by {SenderId} to {RecipientId} of type {RecipientType}", senderId, request.RecipientId, request.RecipientType); @@ -150,9 +150,72 @@ public class ChatsHub : Hub "Message {MessageId} sent successfully from {SenderId} to {RecipientId} ({RecipientType})", result.Message.Id, senderId, request.RecipientId, request.RecipientType); } - + public async Task Remove(RemoveMessageRequest request) { + var removerId = GetUserId(); + _logger.LogInformation("Message removal initiated by {RemoverId} for message {MessageId}", removerId, request.MessageId); + + var removeParams = new DeleteMessage( + DeleterId: removerId, + MessageId: request.MessageId + ); + + try + { + var result = await _messageCommandService.DeleteMessageAsync(removeParams); + if (!result.IsSuccess) + { + _logger.LogError(result.Exception, "Failed to remove message {MessageId} by {RemoverId}. Error: {ErrorMessage}", request.MessageId, removerId, result.Exception?.Message ?? "Unknown error"); + if (result.Exception != null) throw result.Exception; + throw new HubException("Failed to remove message."); + } + + var originalMessage = result.OriginalMessage; // Assuming service returns this + if (originalMessage == null) + { + _logger.LogError("DeleteMessageAsync succeeded but did not return the original message details for message {MessageId}", request.MessageId); + throw new HubException("Failed to process message deletion due to missing message details."); + } + + var removalNotification = new MessageRemovedResponse + { + MessageId = request.MessageId, + SenderId = originalMessage.SenderId, + RecipientId = originalMessage.RecipientId, + RecipientType = originalMessage.RecipientType + }; + + // Notify relevant clients + if (originalMessage.RecipientType == RecipientType.User) + { + await Clients.Group(originalMessage.SenderId.ToString()).SendAsync("MessageRemoved", removalNotification); + if (originalMessage.SenderId != originalMessage.RecipientId) + { + await Clients.Group(originalMessage.RecipientId.ToString()).SendAsync("MessageRemoved", removalNotification); + } + } + else if (originalMessage.RecipientType == RecipientType.Group) + { + await Clients.Group($"group_{originalMessage.RecipientId}").SendAsync("MessageRemoved", removalNotification); + } + _logger.LogInformation("Message {MessageId} removed successfully by {RemoverId}", request.MessageId, removerId); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, "Unauthorized attempt to remove message {MessageId} by user {RemoverId}", request.MessageId, removerId); + throw new HubException("You are not authorized to remove this message.", ex); + } + catch (KeyNotFoundException ex) // Or a custom NotFoundException + { + _logger.LogWarning(ex, "Attempt to remove non-existent message {MessageId} by user {RemoverId}", request.MessageId, removerId); + throw new HubException("Message not found.", ex); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error removing message {MessageId} by {RemoverId}", request.MessageId, removerId); + throw new HubException("An error occurred while removing the message.", ex); + } } public async Task Edit(EditMessageRequest request) diff --git a/Govor.API/Program.cs b/Govor.API/Program.cs index 8f285d3..3232205 100644 --- a/Govor.API/Program.cs +++ b/Govor.API/Program.cs @@ -121,8 +121,8 @@ app.UseAuthorization(); app.MapControllers(); -app.MapHub("/api/chats"); -app.MapHub("/api/friends"); +app.MapHub("/hubs/friends"); +app.MapHub("/hubs/friends"); app.MapSwagger().RequireAuthorization(); diff --git a/Govor.Console/Commands/AcceptFriendRequestCommand.cs b/Govor.Console/Commands/AcceptFriendRequestCommand.cs index 5a54b82..a81be53 100644 --- a/Govor.Console/Commands/AcceptFriendRequestCommand.cs +++ b/Govor.Console/Commands/AcceptFriendRequestCommand.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR.Client; namespace Govor.ConsoleClient.Commands { @@ -24,7 +25,7 @@ namespace Govor.ConsoleClient.Commands try { // API uses Hub for this: AcceptFriendRequest(Guid friendshipId) - await HubConnection.InvokeAsync("AcceptFriendRequest", friendshipId); + await HubConnection.InvokeAsync("AcceptRequest", friendshipId); Console.WriteLine("Заявка в друзья принята."); } catch (Exception ex) diff --git a/Govor.Console/Commands/AdminListAllFriendshipsCommand.cs b/Govor.Console/Commands/AdminListAllFriendshipsCommand.cs index 24aef5a..226c547 100644 --- a/Govor.Console/Commands/AdminListAllFriendshipsCommand.cs +++ b/Govor.Console/Commands/AdminListAllFriendshipsCommand.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Net.Http; using System.Net.Http.Json; +using System.Text.Json; using System.Threading.Tasks; using Govor.Contracts.DTOs; // Required for FriendshipDto @@ -19,15 +20,12 @@ namespace Govor.ConsoleClient.Commands Console.WriteLine("Получение всех дружеских связей (только для администраторов)..."); try { - var response = await HttpClientService.GetAsync("api/admin/Friendships"); - if (response.StatusCode == System.Net.HttpStatusCode.Forbidden) - { - Console.WriteLine("[Ошибка] Доступ запрещен. Эта команда только для администраторов."); - return; - } - response.EnsureSuccessStatusCode(); + var json = await HttpClientService.GetAsync("api/admin/Friendships"); - var friendships = await response.Content.ReadFromJsonAsync>(); + var friendships = JsonSerializer.Deserialize>(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); if (friendships != null && friendships.Any()) { diff --git a/Govor.Console/Commands/AdminListUserFriendshipsCommand.cs b/Govor.Console/Commands/AdminListUserFriendshipsCommand.cs index 3b81970..96b4539 100644 --- a/Govor.Console/Commands/AdminListUserFriendshipsCommand.cs +++ b/Govor.Console/Commands/AdminListUserFriendshipsCommand.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Net.Http; using System.Net.Http.Json; +using System.Text.Json; using System.Threading.Tasks; using Govor.Contracts.DTOs; // Required for FriendshipDto @@ -30,14 +31,8 @@ namespace Govor.ConsoleClient.Commands try { var response = await HttpClientService.GetAsync($"api/admin/Friendships/{userId}"); - if (response.StatusCode == System.Net.HttpStatusCode.Forbidden) - { - Console.WriteLine("[Ошибка] Доступ запрещен. Эта команда только для администраторов."); - return; - } - response.EnsureSuccessStatusCode(); - var friendships = await response.Content.ReadFromJsonAsync>(); + var friendships = JsonSerializer.Deserialize>(response); if (friendships != null && friendships.Any()) { diff --git a/Govor.Console/Commands/AdminRemoveFriendshipCommand.cs b/Govor.Console/Commands/AdminRemoveFriendshipCommand.cs index 07fdea2..aa55ff9 100644 --- a/Govor.Console/Commands/AdminRemoveFriendshipCommand.cs +++ b/Govor.Console/Commands/AdminRemoveFriendshipCommand.cs @@ -32,14 +32,7 @@ namespace Govor.ConsoleClient.Commands // Let's assume query parameter for now as it's simpler for HttpPost. var response = await HttpClientService.PostAsync($"api/admin/Friendships?Id={friendshipId}", null); - - if (response.StatusCode == System.Net.HttpStatusCode.Forbidden) - { - Console.WriteLine("[Ошибка] Доступ запрещен. Эта команда только для администраторов."); - return; - } - response.EnsureSuccessStatusCode(); - + Console.WriteLine($"Дружеская связь {friendshipId} успешно удалена."); } catch (HttpRequestException httpEx) when (httpEx.StatusCode == System.Net.HttpStatusCode.Forbidden) diff --git a/Govor.Console/Commands/BaseCommand.cs b/Govor.Console/Commands/BaseCommand.cs index 358b48d..9706fbe 100644 --- a/Govor.Console/Commands/BaseCommand.cs +++ b/Govor.Console/Commands/BaseCommand.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.SignalR.Client; using System; using System.Threading.Tasks; @@ -6,13 +7,13 @@ namespace Govor.ConsoleClient.Commands public abstract class BaseCommand : ICommand { protected static FriendsClient? FriendsClient { get; private set; } - protected static HttpClientService HttpClientService { get; private set; } - protected static Func GetAuthToken { get; private set; } - protected static Action SetAuthToken { get; private set; } - protected static Func InitializeHubConnectionAsync { get; private set; } - protected static Microsoft.AspNetCore.SignalR.Client.HubConnection? HubConnection => _hubConnection?.Invoke(); - private static Func _hubConnection; - + protected static HttpClientService HttpClientService { get; private set; } = null!; + protected static Func GetAuthToken { get; private set; } = null!; + protected static Action SetAuthToken { get; private set; } = null!; + protected static Func InitializeHubConnectionAsync { get; private set; } = null!; + protected static HubConnection HubConnection => _getHubConnection?.Invoke(); + + private static Func _getHubConnection = null!; public static void InitializeServices( FriendsClient? friendsClient, @@ -20,43 +21,42 @@ namespace Govor.ConsoleClient.Commands Func getAuthToken, Action setAuthToken, Func initializeHubConnectionAsync, - Func hubConnection) + Func getHubConnection) { FriendsClient = friendsClient; HttpClientService = httpClientService; GetAuthToken = getAuthToken; SetAuthToken = setAuthToken; InitializeHubConnectionAsync = initializeHubConnectionAsync; - _hubConnection = hubConnection; + _getHubConnection = getHubConnection; } public abstract Task ExecuteAsync(string? argument); public abstract string GetHelp(); - protected boolEnsureLoggedIn() + protected bool EnsureLoggedIn() { if (GetAuthToken() == null) { - System.Console.WriteLine("[Ошибка] Сначала войдите в систему. Используйте /login или /reg."); + Console.WriteLine("[Ошибка] Сначала войдите в систему. Используйте /login или /reg."); return false; } - if (FriendsClient == null && !(this is LoginCommand || this is RegisterCommand)) // FriendsClient might not be needed for auth commands + + if (FriendsClient == null && !(this is LoginCommand || this is RegisterCommand)) { - // Attempt to re-initialize FriendsClient if token exists but client is null - // This can happen if the app was closed and token was persisted, but client was not re-instantiated. - // However, the current setup in Program.cs initializes FriendsClient after login/reg. - // This check is more of a safeguard. - System.Console.WriteLine("[Ошибка] Клиент для работы с друзьями не инициализирован. Попробуйте войти снова."); - return false; + Console.WriteLine("[Ошибка] Клиент для работы с друзьями не инициализирован. Попробуйте войти снова."); + return false; } + return true; } - protected boolEnsureHubConnection() + protected bool EnsureHubConnection() { - if (HubConnection == null || HubConnection.State != Microsoft.AspNetCore.SignalR.Client.HubConnectionState.Connected) + var hub = _getHubConnection?.Invoke(); + if (hub == null || hub.State != HubConnectionState.Connected) { - System.Console.WriteLine("[Ошибка] SignalR соединение не установлено. Попробуйте войти снова или проверьте соединение."); + Console.WriteLine("[Ошибка] SignalR соединение не установлено. Попробуйте войти снова или проверьте соединение."); return false; } return true; diff --git a/Govor.Console/Commands/BlockUserCommand.cs b/Govor.Console/Commands/BlockUserCommand.cs index 766bef5..bf83a39 100644 --- a/Govor.Console/Commands/BlockUserCommand.cs +++ b/Govor.Console/Commands/BlockUserCommand.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR.Client; namespace Govor.ConsoleClient.Commands { diff --git a/Govor.Console/Commands/ListOutgoingRequestsCommand.cs b/Govor.Console/Commands/ListOutgoingRequestsCommand.cs index 992dd60..ffa272d 100644 --- a/Govor.Console/Commands/ListOutgoingRequestsCommand.cs +++ b/Govor.Console/Commands/ListOutgoingRequestsCommand.cs @@ -2,6 +2,7 @@ using System; using System.Linq; using System.Net.Http; using System.Net.Http.Json; +using System.Text.Json; using System.Threading.Tasks; using Govor.Contracts.DTOs; // Required for FriendshipDto @@ -17,10 +18,12 @@ namespace Govor.ConsoleClient.Commands { // This functionality maps to GET api/friends/responses in FriendsRequestQueryController // FriendsClient doesn't have a dedicated method, so we use HttpClientService or direct HttpClient - var response = await HttpClientService.GetAsync("api/friends/responses"); - response.EnsureSuccessStatusCode(); + var json = await HttpClientService.GetAsync("api/friends/responses"); - var requests = await response.Content.ReadFromJsonAsync>(); + var requests = JsonSerializer.Deserialize>(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); if (requests != null && requests.Any()) { diff --git a/Govor.Console/Commands/LoginCommand.cs b/Govor.Console/Commands/LoginCommand.cs index 94b8665..9f2d017 100644 --- a/Govor.Console/Commands/LoginCommand.cs +++ b/Govor.Console/Commands/LoginCommand.cs @@ -30,8 +30,9 @@ namespace Govor.ConsoleClient.Commands sharedClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", authToken); var friendsClient = new FriendsClient(sharedClient); - // Re-initialize services in BaseCommand with the new FriendsClient - InitializeServices(friendsClient, HttpClientService, GetAuthToken, SetAuthToken, InitializeHubConnectionAsync, HubConnection); + + + Program.UpdateFriendsClient(friendsClient); // <-- единственный нужный вызов await InitializeHubConnectionAsync(); Console.WriteLine("[Успех] Вход выполнен. Токен сохранен."); diff --git a/Govor.Console/Commands/RegisterCommand.cs b/Govor.Console/Commands/RegisterCommand.cs index c1bcfc0..2dd84e7 100644 --- a/Govor.Console/Commands/RegisterCommand.cs +++ b/Govor.Console/Commands/RegisterCommand.cs @@ -34,7 +34,7 @@ namespace Govor.ConsoleClient.Commands var friendsClient = new FriendsClient(sharedClient); // Re-initialize services in BaseCommand with the new FriendsClient - InitializeServices(friendsClient, HttpClientService, GetAuthToken, SetAuthToken, InitializeHubConnectionAsync, HubConnection); + Program.UpdateFriendsClient(friendsClient); // <-- единственный нужный вызов await InitializeHubConnectionAsync(); Console.WriteLine("[Успех] Регистрация завершена. Токен сохранен."); diff --git a/Govor.Console/Commands/RejectFriendRequestCommand.cs b/Govor.Console/Commands/RejectFriendRequestCommand.cs index f2a0f83..afd9673 100644 --- a/Govor.Console/Commands/RejectFriendRequestCommand.cs +++ b/Govor.Console/Commands/RejectFriendRequestCommand.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR.Client; namespace Govor.ConsoleClient.Commands { @@ -25,7 +26,7 @@ namespace Govor.ConsoleClient.Commands { // API uses Hub for this: RejectFriendRequest(Guid friendshipId) // Located in Govor.API/Hubs/FriendsHub.cs - await HubConnection.InvokeAsync("RejectFriendRequest", friendshipId); + await HubConnection.InvokeAsync("RejectRequest", friendshipId); Console.WriteLine("Заявка в друзья отклонена."); } catch (Exception ex) diff --git a/Govor.Console/Commands/SendFriendRequestCommand.cs b/Govor.Console/Commands/SendFriendRequestCommand.cs index 62aae31..62c65fe 100644 --- a/Govor.Console/Commands/SendFriendRequestCommand.cs +++ b/Govor.Console/Commands/SendFriendRequestCommand.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR.Client; namespace Govor.ConsoleClient.Commands { @@ -24,7 +25,7 @@ namespace Govor.ConsoleClient.Commands try { // API uses Hub for this: SendFriendRequest(Guid targetUserId) - await HubConnection.InvokeAsync("SendFriendRequest", targetUserId); + await HubConnection.InvokeAsync("SendRequest", targetUserId); Console.WriteLine("Запрос в друзья отправлен."); } catch (Exception ex) diff --git a/Govor.Console/Commands/UnblockUserCommand.cs b/Govor.Console/Commands/UnblockUserCommand.cs index bc2dae4..5fa17fc 100644 --- a/Govor.Console/Commands/UnblockUserCommand.cs +++ b/Govor.Console/Commands/UnblockUserCommand.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR.Client; namespace Govor.ConsoleClient.Commands { diff --git a/Govor.Console/FriendsHubClient.cs b/Govor.Console/FriendsHubClient.cs new file mode 100644 index 0000000..f8667d2 --- /dev/null +++ b/Govor.Console/FriendsHubClient.cs @@ -0,0 +1,157 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR; +using Microsoft.AspNetCore.SignalR.Client; + +class FriendsHubClient +{ + private readonly HubConnection _connection; + + public FriendsHubClient(string hubUrl, string jwtToken) + { + _connection = new HubConnectionBuilder() + .WithUrl(hubUrl, options => + { + options.AccessTokenProvider = () => Task.FromResult(jwtToken); + }) + .WithAutomaticReconnect() + .Build(); + + // Подписка на события от сервера + _connection.On("FriendRequestReceived", OnFriendRequestReceived); + _connection.On("FriendRequestAccepted", OnFriendRequestAccepted); + _connection.On("FriendRequestRejected", OnFriendRequestRejected); + } + + private void OnFriendRequestReceived(Guid userId) + { + Console.WriteLine($"[Event] Получен запрос в друзья от пользователя: {userId}"); + } + + private void OnFriendRequestAccepted(Guid friendshipId) + { + Console.WriteLine($"[Event] Запрос в друзья принят. ID дружбы: {friendshipId}"); + } + + private void OnFriendRequestRejected(Guid friendshipId) + { + Console.WriteLine($"[Event] Запрос в друзья отклонён. ID дружбы: {friendshipId}"); + } + + public async Task StartAsync() + { + await _connection.StartAsync(); + Console.WriteLine("Подключено к FriendsHub"); + } + + public async Task StopAsync() + { + await _connection.StopAsync(); + Console.WriteLine("Отключено от FriendsHub"); + } + + public async Task SendRequest(Guid targetUserId) + { + try + { + await _connection.InvokeAsync("SendRequest", targetUserId); + Console.WriteLine($"Запрос на добавление в друзья отправлен пользователю {targetUserId}"); + } + catch (HubException ex) + { + Console.WriteLine($"Ошибка при отправке запроса: {ex.Message}"); + } + } + + public async Task AcceptRequest(Guid friendshipId) + { + try + { + await _connection.InvokeAsync("AcceptRequest", friendshipId); + Console.WriteLine($"Запрос в друзья с ID {friendshipId} принят"); + } + catch (HubException ex) + { + Console.WriteLine($"Ошибка при принятии запроса: {ex.Message}"); + } + } + + public async Task RejectRequest(Guid friendshipId) + { + try + { + await _connection.InvokeAsync("RejectRequest", friendshipId); + Console.WriteLine($"Запрос в друзья с ID {friendshipId} отклонён"); + } + catch (HubException ex) + { + Console.WriteLine($"Ошибка при отклонении запроса: {ex.Message}"); + } + } +} + +class Program +{ + static async Task TestMain(string[] args) + { + // Адрес хаба SignalR + string hubUrl = "https://yourserver.com/api/friends"; + + // JWT токен для аутентификации (замени на настоящий) + string jwtToken = "your_jwt_token_here"; + + var client = new FriendsHubClient(hubUrl, jwtToken); + + await client.StartAsync(); + + Console.WriteLine("Команды:"); + Console.WriteLine("send - Отправить запрос в друзья"); + Console.WriteLine("accept - Принять запрос"); + Console.WriteLine("reject - Отклонить запрос"); + Console.WriteLine("exit - Выйти"); + + while (true) + { + Console.Write("> "); + var input = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(input)) + continue; + + var parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + var command = parts[0].ToLowerInvariant(); + + if (command == "exit") + break; + + if (parts.Length < 2) + { + Console.WriteLine("Ошибка: не хватает аргументов"); + continue; + } + + if (!Guid.TryParse(parts[1], out var id)) + { + Console.WriteLine("Ошибка: некорректный GUID"); + continue; + } + + switch (command) + { + case "send": + await client.SendRequest(id); + break; + case "accept": + await client.AcceptRequest(id); + break; + case "reject": + await client.RejectRequest(id); + break; + default: + Console.WriteLine("Неизвестная команда"); + break; + } + } + + await client.StopAsync(); + } +} diff --git a/Govor.Console/HttpClientService.cs b/Govor.Console/HttpClientService.cs index 93aa10f..9482b92 100644 --- a/Govor.Console/HttpClientService.cs +++ b/Govor.Console/HttpClientService.cs @@ -18,6 +18,28 @@ namespace Govor.ConsoleClient }; } + public string GetBaseUrl() => _client.BaseAddress?.ToString() ?? string.Empty; + + public async Task GetAsync(string uri) + { + var response = await _client.GetAsync(uri); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(); + } + + public async Task PostAsync(string uri, object? payload = null) + { + HttpResponseMessage response; + + if (payload is null) + response = await _client.PostAsync(uri, null); + else + response = await _client.PostAsJsonAsync(uri, payload); + + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(); + } + public async Task RegisterAsync(string username, string password, string inviteCode) { var request = new RegistrationRequest diff --git a/Govor.Console/Program.cs b/Govor.Console/Program.cs index 1c8760c..4609304 100644 --- a/Govor.Console/Program.cs +++ b/Govor.Console/Program.cs @@ -9,6 +9,7 @@ using System.Threading.Tasks; using Govor.ConsoleClient.Commands; using Govor.Contracts.Requests.SignalR; using Govor.Contracts.Responses.SignalR; +using Govor.Core.Models; /*==================================== *Личные сообщения| Егор @@ -194,7 +195,7 @@ namespace Govor.ConsoleClient } _hubConnection = new HubConnectionBuilder() - .WithUrl($"{baseUrl}/api/chats", options => + .WithUrl($"{baseUrl}/hubs/chats", options => { options.AccessTokenProvider = () => Task.FromResult(AuthToken); }) @@ -203,7 +204,7 @@ namespace Govor.ConsoleClient _hubConnection.On("ReceiveMessage", (message) => { var myId = GetMyUserId(); - var senderName = message.SenderId == myId ? "Ты" : CurrentChatUser ?? message.SenderUsername ?? "Неизвестно"; + var senderName = message.SenderId == myId ? "Ты" : CurrentChatUser ?? message.SenderId.ToString() ?? "Неизвестно"; var displayMessage = $"[{message.SentAt:HH:mm}] {senderName} >> {message.EncryptedContent}"; // Determine chat key based on sender/recipient, ensuring consistency @@ -230,7 +231,7 @@ namespace Govor.ConsoleClient } else // Notification for message not in the current chat { - Console.WriteLine($"\n[Новое сообщение от {message.SenderUsername ?? message.SenderId.ToString()}]: {message.EncryptedContent}"); + Console.WriteLine($"\n[Новое сообщение от {message.SenderId.ToString()}]: {message.EncryptedContent}"); Console.Write(CurrentChatUser == null ? "/> " : ">> "); // Re-display prompt } }); @@ -331,7 +332,7 @@ namespace Govor.ConsoleClient { RecipientId = CurrentChatUserId, EncryptedContent = messageContent, - RecipientType = RecipientType.User, + RecipientType = RecipientType.User }; try diff --git a/Govor.Contracts/Responses/SignalR/MessageRemovedResponse.cs b/Govor.Contracts/Responses/SignalR/MessageRemovedResponse.cs new file mode 100644 index 0000000..2fd3ff8 --- /dev/null +++ b/Govor.Contracts/Responses/SignalR/MessageRemovedResponse.cs @@ -0,0 +1,11 @@ +using Govor.Core.Models; + +namespace Govor.Contracts.Responses.SignalR; + +public class MessageRemovedResponse +{ + public Guid MessageId { get; set; } + public Guid SenderId { get; set; } + public Guid RecipientId { get; set; } + public RecipientType RecipientType { get; set; } +} \ No newline at end of file From c7de2318fcdc4a0e1dc598eb23e0e90c2d649af2 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Fri, 11 Jul 2025 21:52:37 +0700 Subject: [PATCH 044/101] using Govor.Core.Models.Users; --- .../Validators/MessageValidatorTests.cs | 1 + .../Validators/UserValidatorTests.cs | 1 + .../AdminStuff/FriendshipsController.cs | 1 + .../Controllers/AdminStuff/UsersController.cs | 3 +- .../Friends/FriendshipController.cs | 2 +- .../ConfigurationProgramExtensions.cs | 3 +- Govor.API/Hubs/ChatsHub.cs | 268 +++++++++++------- Govor.API/Program.cs | 2 +- .../Authentication/AuthServiceTests.cs | 1 + .../Authentication/JwtServiceTests.cs | 4 +- .../Friends/FriendRequestQueryServiceTests.cs | 1 + .../Friends/FriendshipServiceTests.cs | 1 + .../Messages/MessageCommandServiceTests.cs | 3 +- .../Services/PingHandlerServiceTests.cs | 2 +- .../AdminsStuff/UsersService.cs | 6 +- .../Authentication/IInvitesService.cs | 1 + .../Interfaces/Authentication/IJwtService.cs | 4 +- .../Interfaces/Friends/IFriendshipService.cs | 2 +- .../Interfaces/IFriendsService.cs | 1 + Govor.Application/Interfaces/IGroupService.cs | 1 + .../Interfaces/IMessagesLoader.cs | 2 +- .../Interfaces/IUsersAdministration.cs | 4 +- .../Interfaces/Medias/IMediaService.cs | 2 +- .../Messages/IMessageCommandService.cs | 2 +- .../Messages/Parameters/SendMessage.cs | 2 +- .../Services/Authentication/AuthService.cs | 4 +- .../Services/Authentication/JwtService.cs | 3 +- .../Services/Friends/FriendshipService.cs | 1 + Govor.Application/Services/InvitesService.cs | 1 + .../Messages/MessageCommandService.cs | 22 +- .../Services/Messages/MessagesLoader.cs | 2 +- Govor.Console/Program.cs | 3 +- .../Requests/MediaUploadRequest.cs | 3 +- .../Requests/SignalR/MessageRequest.cs | 2 +- .../Responses/SignalR/MessageEditResponse.cs | 6 + .../SignalR/MessageRemovedResponse.cs | 2 +- .../Responses/SignalR/UserMessageResponse.cs | 2 +- .../Validators/AdminValidator.cs | 2 +- .../Validators/MediaAttachmentsValidator.cs | 2 +- .../Validators/MessageValidator.cs | 2 +- .../Validators/UserValidator.cs | 2 +- Govor.Core/Models/Friendship.cs | 2 + Govor.Core/Models/GroupInvitation.cs | 2 + Govor.Core/Models/Invitation.cs | 2 + Govor.Core/Models/MediaFile.cs | 2 + .../Models/{ => Messages}/MediaAttachments.cs | 2 +- Govor.Core/Models/{ => Messages}/Message.cs | 2 +- .../Models/{ => Messages}/MessageReaction.cs | 4 +- .../Models/{ => Messages}/MessageView.cs | 2 +- Govor.Core/Models/PrivateChat.cs | 2 + Govor.Core/Models/{ => Users}/Admin.cs | 2 +- .../Models/Users/PrivacyUserSettings.cs | 39 +++ Govor.Core/Models/{ => Users}/User.cs | 2 +- .../Repositories/Admins/IAdminsExist.cs | 2 +- .../Repositories/Admins/IAdminsReader.cs | 2 +- .../Repositories/Admins/IAdminsWriter.cs | 2 +- .../Groups/IGroupMessagesReader.cs | 2 +- .../IMediaAttachmentsExist.cs | 2 +- .../IMediaAttachmentsReader.cs | 2 +- .../IMediaAttachmentsWriter.cs | 2 +- .../Repositories/Messages/IMessagesExist.cs | 2 +- .../Repositories/Messages/IMessagesReader.cs | 2 +- .../Repositories/Messages/IMessagesWriter.cs | 2 +- Govor.Core/Repositories/Users/IUsersExist.cs | 2 +- Govor.Core/Repositories/Users/IUsersReader.cs | 2 +- Govor.Core/Repositories/Users/IUsersWriter.cs | 2 +- .../Repositories/AdminsRepositoryTests.cs | 1 + .../Repositories/MediaAttachmentsTests.cs | 1 + .../Repositories/MessagesRepositoryTests.cs | 1 + .../Repositories/UsersRepositoryTests.cs | 1 + .../Configurations/AdminConfiguration.cs | 2 +- .../MediaAttachmentsConfiguration.cs | 2 +- .../MessageReactionConfiguration.cs | 2 +- .../MessageViewConfiguration.cs | 2 +- .../Configurations/MessagesConfiguration.cs | 2 +- .../Configurations/UserConfiguration.cs | 2 +- Govor.Data/GovorDbContext.cs | 2 + Govor.Data/Repositories/AdminsRepository.cs | 1 + .../Repositories/FriendshipsRepository.cs | 1 + Govor.Data/Repositories/GroupRepository.cs | 2 +- Govor.Data/Repositories/InvitesRepository.cs | 1 + .../MediaAttachmentsRepository.cs | 2 +- Govor.Data/Repositories/MessagesRepository.cs | 1 + Govor.Data/Repositories/UsersRepository.cs | 3 +- 84 files changed, 317 insertions(+), 180 deletions(-) create mode 100644 Govor.Contracts/Responses/SignalR/MessageEditResponse.cs rename Govor.Core/Models/{ => Messages}/MediaAttachments.cs (93%) rename Govor.Core/Models/{ => Messages}/Message.cs (97%) rename Govor.Core/Models/{ => Messages}/MessageReaction.cs (84%) rename Govor.Core/Models/{ => Messages}/MessageView.cs (82%) rename Govor.Core/Models/{ => Users}/Admin.cs (82%) create mode 100644 Govor.Core/Models/Users/PrivacyUserSettings.cs rename Govor.Core/Models/{ => Users}/User.cs (97%) diff --git a/Govor.API.Tests/UnitTests/Infrastructure/Validators/MessageValidatorTests.cs b/Govor.API.Tests/UnitTests/Infrastructure/Validators/MessageValidatorTests.cs index c171087..8472243 100644 --- a/Govor.API.Tests/UnitTests/Infrastructure/Validators/MessageValidatorTests.cs +++ b/Govor.API.Tests/UnitTests/Infrastructure/Validators/MessageValidatorTests.cs @@ -1,6 +1,7 @@ using AutoFixture; using Govor.Core.Infrastructure.Validators; using Govor.Core.Models; +using Govor.Core.Models.Messages; namespace Govor.API.Tests.UnitTests.Infrastructure.Validators; diff --git a/Govor.API.Tests/UnitTests/Infrastructure/Validators/UserValidatorTests.cs b/Govor.API.Tests/UnitTests/Infrastructure/Validators/UserValidatorTests.cs index 6c1e228..c7d9162 100644 --- a/Govor.API.Tests/UnitTests/Infrastructure/Validators/UserValidatorTests.cs +++ b/Govor.API.Tests/UnitTests/Infrastructure/Validators/UserValidatorTests.cs @@ -1,6 +1,7 @@ using AutoFixture; using Govor.Core.Infrastructure.Validators; using Govor.Core.Models; +using Govor.Core.Models.Users; namespace Govor.API.Tests.UnitTests.Infrastructure.Validators; diff --git a/Govor.API/Controllers/AdminStuff/FriendshipsController.cs b/Govor.API/Controllers/AdminStuff/FriendshipsController.cs index a124dbf..502abf4 100644 --- a/Govor.API/Controllers/AdminStuff/FriendshipsController.cs +++ b/Govor.API/Controllers/AdminStuff/FriendshipsController.cs @@ -1,5 +1,6 @@ using Govor.Contracts.DTOs; using Govor.Core.Models; +using Govor.Core.Models.Users; using Govor.Core.Repositories.Friendships; using Govor.Data.Repositories.Exceptions; using Microsoft.AspNetCore.Authorization; diff --git a/Govor.API/Controllers/AdminStuff/UsersController.cs b/Govor.API/Controllers/AdminStuff/UsersController.cs index dd7e67f..fa75d8e 100644 --- a/Govor.API/Controllers/AdminStuff/UsersController.cs +++ b/Govor.API/Controllers/AdminStuff/UsersController.cs @@ -1,6 +1,7 @@ using Govor.API.Services.AdminsStuff.Interfaces; +using Govor.Application.Interfaces; using Govor.Contracts.Responses.Admins; -using Govor.Core.Models; +using Govor.Core.Models.Users; using Govor.Data.Repositories.Exceptions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/Govor.API/Controllers/Friends/FriendshipController.cs b/Govor.API/Controllers/Friends/FriendshipController.cs index 2aff5e0..e8bb89d 100644 --- a/Govor.API/Controllers/Friends/FriendshipController.cs +++ b/Govor.API/Controllers/Friends/FriendshipController.cs @@ -2,7 +2,7 @@ using Govor.Application.Exceptions.FriendsService; using Govor.Application.Interfaces.Friends; using Govor.Application.Interfaces.Infrastructure.Extensions; using Govor.Contracts.DTOs; -using Govor.Core.Models; +using Govor.Core.Models.Users; using Microsoft.AspNetCore.Mvc; namespace Govor.API.Controllers.Friends; diff --git a/Govor.API/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Extensions/ConfigurationProgramExtensions.cs index f85954e..b687326 100644 --- a/Govor.API/Extensions/ConfigurationProgramExtensions.cs +++ b/Govor.API/Extensions/ConfigurationProgramExtensions.cs @@ -4,7 +4,6 @@ using Govor.Application.Infrastructure.AdminsStuff; using Govor.Application.Infrastructure.Extensions; using Govor.Application.Infrastructure.Validators; using Govor.Application.Interfaces; -using Govor.Application.Interfaces.AdminsStuff; using Govor.Application.Interfaces.Authentication; using Govor.Application.Interfaces.Friends; using Govor.Application.Interfaces.Infrastructure.Extensions; @@ -16,6 +15,8 @@ using Govor.Application.Services.Messages; using Govor.Core.Infrastructure.Extensions; using Govor.Core.Infrastructure.Validators; using Govor.Core.Models; +using Govor.Core.Models.Messages; +using Govor.Core.Models.Users; using Govor.Core.Repositories.Admins; using Govor.Core.Repositories.Friendships; using Govor.Core.Repositories.Groups; diff --git a/Govor.API/Hubs/ChatsHub.cs b/Govor.API/Hubs/ChatsHub.cs index af8c6e3..cdd9d73 100644 --- a/Govor.API/Hubs/ChatsHub.cs +++ b/Govor.API/Hubs/ChatsHub.cs @@ -1,10 +1,10 @@ -using Govor.API.Services; +using Govor.Application.Exceptions.VerifyFriendship; using Govor.Application.Interfaces; using Govor.Application.Interfaces.Messages; using Govor.Application.Interfaces.Messages.Parameters; using Govor.Contracts.Requests.SignalR; using Govor.Contracts.Responses.SignalR; -using Govor.Core.Models; +using Govor.Core.Models.Messages; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.SignalR; @@ -85,143 +85,191 @@ public class ChatsHub : Hub await base.OnDisconnectedAsync(exception); } - public async Task Send(MessageRequest request) + public async Task> Send(MessageRequest request) { var senderId = GetUserId(); - + if (string.IsNullOrWhiteSpace(request.EncryptedContent) && (request.MediaAttachments == null || !request.MediaAttachments.Any())) { - _logger.LogWarning("Empty message (no content and no attachments) received from user {UserId}", senderId); - throw new ArgumentException("Message cannot be empty (must have content or attachments)."); + _logger.LogWarning("Empty message received from user {UserId}", senderId); + return HubResult.BadRequest("Message must contain content or media."); } - _logger.LogInformation("Message send initiated by {SenderId} to {RecipientId} of type {RecipientType}", + _logger.LogInformation("Sending message from {SenderId} to {RecipientId} ({RecipientType})", senderId, request.RecipientId, request.RecipientType); - var sendMessageParams = new SendMessage(EncryptContent: request.EncryptedContent, - ReplyToMessageId: request.ReplyToMessageId, FromUserId: senderId, RecipientId: request.RecipientId, - RecipientType: request.RecipientType, SendAt: DateTime.UtcNow, + var sendMessageParams = new SendMessage( + EncryptContent: request.EncryptedContent, + ReplyToMessageId: request.ReplyToMessageId, + FromUserId: senderId, + RecipientId: request.RecipientId, + RecipientType: request.RecipientType, + SendAt: DateTime.UtcNow, Media: request.MediaAttachments?.Select(f => new SendMedia(f.MediaId, f.EncryptedKey)) ?? - Array.Empty()); - - var result = await _messageCommandService.SendMessageAsync(sendMessageParams); - - if (!result.IsSuccess || result.Message.Id == Guid.Empty) - { - _logger.LogError(result.Exception, - "Failed to send message from {SenderId} to {RecipientId}. Error: {ErrorMessage}", senderId, - request.RecipientId, result.Exception?.Message ?? "Unknown error"); - if (result.Exception != null) - throw result.Exception; - throw new HubException("Failed to send message due to an internal error."); - } - - var messageResponse = new UserMessageResponse // Assuming a response DTO - { - MessageId = result.Message.Id, - SenderId = result.Message.SenderId, - RecipientId = result.Message.RecipientId, - RecipientType = result.Message.RecipientType, - EncryptedContent = result.Message.EncryptedContent, - SentAt = result.Message.SentAt, - IsEdited = false, - MediaAttachments = result.Message.MediaAttachments.Select(m => m.MediaFile).ToList(), - ReplyToMessageId = request.ReplyToMessageId - }; - - // Notify recipient (user or group) - if (request.RecipientType == RecipientType.User) - { - // Send to the recipient's personal group - await Clients.Group(request.RecipientId.ToString()).SendAsync("ReceiveMessage", messageResponse); - } - else if (request.RecipientType == RecipientType.Group) - { - // Send to all members of the group, including the sender if they are part of the group via a different connection - await Clients.Group($"group_{request.RecipientId}").SendAsync("ReceiveMessage", messageResponse); - } - - // Notify sender (confirmation) on their connection - await Clients.Caller.SendAsync("MessageSent", - messageResponse); // Or use "ReceiveMessage" if the sender should also just get it like anyone else - - _logger.LogInformation( - "Message {MessageId} sent successfully from {SenderId} to {RecipientId} ({RecipientType})", - result.Message.Id, senderId, request.RecipientId, request.RecipientType); - } - - public async Task Remove(RemoveMessageRequest request) - { - var removerId = GetUserId(); - _logger.LogInformation("Message removal initiated by {RemoverId} for message {MessageId}", removerId, request.MessageId); - - var removeParams = new DeleteMessage( - DeleterId: removerId, - MessageId: request.MessageId + Array.Empty() ); - + try { - var result = await _messageCommandService.DeleteMessageAsync(removeParams); - if (!result.IsSuccess) - { - _logger.LogError(result.Exception, "Failed to remove message {MessageId} by {RemoverId}. Error: {ErrorMessage}", request.MessageId, removerId, result.Exception?.Message ?? "Unknown error"); - if (result.Exception != null) throw result.Exception; - throw new HubException("Failed to remove message."); - } - - var originalMessage = result.OriginalMessage; // Assuming service returns this - if (originalMessage == null) - { - _logger.LogError("DeleteMessageAsync succeeded but did not return the original message details for message {MessageId}", request.MessageId); - throw new HubException("Failed to process message deletion due to missing message details."); - } + var result = await _messageCommandService.SendMessageAsync(sendMessageParams); - var removalNotification = new MessageRemovedResponse - { - MessageId = request.MessageId, - SenderId = originalMessage.SenderId, - RecipientId = originalMessage.RecipientId, - RecipientType = originalMessage.RecipientType - }; + if (!result.IsSuccess || result.Message.Id == Guid.Empty) + return LogAndError(senderId, request.RecipientId, "Failed to send message", result.Exception); - // Notify relevant clients - if (originalMessage.RecipientType == RecipientType.User) - { - await Clients.Group(originalMessage.SenderId.ToString()).SendAsync("MessageRemoved", removalNotification); - if (originalMessage.SenderId != originalMessage.RecipientId) - { - await Clients.Group(originalMessage.RecipientId.ToString()).SendAsync("MessageRemoved", removalNotification); - } - } - else if (originalMessage.RecipientType == RecipientType.Group) - { - await Clients.Group($"group_{originalMessage.RecipientId}").SendAsync("MessageRemoved", removalNotification); - } - _logger.LogInformation("Message {MessageId} removed successfully by {RemoverId}", request.MessageId, removerId); + var response = BuildUserMessageResponse(result.Message, request.ReplyToMessageId); + + await NotifyClientsAboutMessage(response); + + return HubResult.Ok(response); } catch (UnauthorizedAccessException ex) { - _logger.LogWarning(ex, "Unauthorized attempt to remove message {MessageId} by user {RemoverId}", request.MessageId, removerId); - throw new HubException("You are not authorized to remove this message.", ex); + return LogAndUnauthorized(ex, "Unauthorized sending attempt", senderId, + request.RecipientId); } - catch (KeyNotFoundException ex) // Or a custom NotFoundException + catch (FriendshipException) { - _logger.LogWarning(ex, "Attempt to remove non-existent message {MessageId} by user {RemoverId}", request.MessageId, removerId); - throw new HubException("Message not found.", ex); + return HubResult.Unauthorized( + "You cannot send this message because you are not friends."); + } + catch (ArgumentException) + { + return HubResult.BadRequest("Invalid message content."); } catch (Exception ex) { - _logger.LogError(ex, "Error removing message {MessageId} by {RemoverId}", request.MessageId, removerId); - throw new HubException("An error occurred while removing the message.", ex); + return LogAndError(senderId, request.RecipientId, "Unhandled exception", ex); } } - public async Task Edit(EditMessageRequest request) + + public async Task> Remove(RemoveMessageRequest request) { + var removerId = GetUserId(); + _logger.LogInformation("Removing message {MessageId} by user {RemoverId}", request.MessageId, removerId); + + try + { + var result = await _messageCommandService.DeleteMessageAsync(new DeleteMessage(removerId, request.MessageId)); + + if (!result.IsSuccess || result.OriginalMessage == null) + return LogAndError(removerId, request.MessageId, "Message deletion failed", result.Exception); + + var notification = new MessageRemovedResponse + { + MessageId = request.MessageId, + SenderId = result.OriginalMessage.SenderId, + RecipientId = result.OriginalMessage.RecipientId, + RecipientType = result.OriginalMessage.RecipientType + }; + + await NotifyClientsAboutRemoval(notification); + + _logger.LogInformation("Message {MessageId} removed successfully by {RemoverId}", request.MessageId, removerId); + return HubResult.Ok(notification); + } + catch (UnauthorizedAccessException ex) + { + return LogAndUnauthorized(ex, "Unauthorized removal", removerId, request.MessageId); + } + catch (KeyNotFoundException ex) + { + return LogAndNotFound(ex, "Message not found", removerId, request.MessageId); + } + catch (Exception ex) + { + return LogAndError(removerId, request.MessageId, "Unhandled deletion error", ex); + } } + + public async Task> Edit(EditMessageRequest request) + { + var editor = GetUserId(); + _logger.LogInformation("Editing message {MessageId} by user {EditorId}", request.MessageId, editor); + + var editMessageParam = new EditMessage(editor, + request.MessageId, + request.NewEncryptedContent, + DateTime.UtcNow); + + try + { + var result = await _messageCommandService.EditMessageAsync(editMessageParam); + + if(!result.IsSuccess) + return LogAndError(editor, request.MessageId, "Edit message error", result.Exception); + + return HubResult.Ok(); + } + catch (Exception ex) + { + return LogAndError(editor, request.MessageId, "Unhandled exception error", ex); + } + } + + + private UserMessageResponse BuildUserMessageResponse(Message message, Guid? replyToId) + { + return new UserMessageResponse + { + MessageId = message.Id, + SenderId = message.SenderId, + RecipientId = message.RecipientId, + RecipientType = message.RecipientType, + EncryptedContent = message.EncryptedContent, + SentAt = message.SentAt, + IsEdited = false, + MediaAttachments = message.MediaAttachments.Select(m => m.MediaFile).ToList(), + ReplyToMessageId = replyToId + }; + } + + private async Task NotifyClientsAboutMessage(UserMessageResponse response) + { + string group = response.RecipientType == RecipientType.User + ? response.RecipientId.ToString() + : $"group_{response.RecipientId}"; + + await Clients.Group(group).SendAsync("ReceiveMessage", response); + await Clients.Caller.SendAsync("MessageSent", response); + } + + private async Task NotifyClientsAboutRemoval(MessageRemovedResponse response) + { + if (response.RecipientType == RecipientType.User) + { + await Clients.Group(response.SenderId.ToString()).SendAsync("MessageRemoved", response); + if (response.SenderId != response.RecipientId) + await Clients.Group(response.RecipientId.ToString()).SendAsync("MessageRemoved", response); + } + else + { + await Clients.Group($"group_{response.RecipientId}").SendAsync("MessageRemoved", response); + } + } + + // Logging helpers + private HubResult LogAndError(Guid userId, Guid targetId, string message, Exception ex) + { + _logger.LogError(ex, "{Message} from {UserId} to {TargetId}", message, userId, targetId); + return HubResult.Error(ex?.Message ?? "Internal server error"); + } + + private HubResult LogAndUnauthorized(Exception ex, string msg, Guid userId, Guid targetId) + { + _logger.LogWarning(ex, "{Msg}: {UserId} -> {TargetId}", msg, userId, targetId); + return HubResult.Unauthorized("You are not authorized to perform this action."); + } + + private HubResult LogAndNotFound(Exception ex, string msg, Guid userId, Guid targetId) + { + _logger.LogWarning(ex, "{Msg}: {UserId} -> {TargetId}", msg, userId, targetId); + return HubResult.NotFound("Message not found."); + } + + private Guid GetUserId(bool suppressException = false) { var userIdClaim = Context.User?.FindFirst("userId")?.Value; diff --git a/Govor.API/Program.cs b/Govor.API/Program.cs index 3232205..cacd149 100644 --- a/Govor.API/Program.cs +++ b/Govor.API/Program.cs @@ -121,7 +121,7 @@ app.UseAuthorization(); app.MapControllers(); -app.MapHub("/hubs/friends"); +app.MapHub("/hubs/chats"); app.MapHub("/hubs/friends"); app.MapSwagger().RequireAuthorization(); diff --git a/Govor.Application.Tests/Services/Authentication/AuthServiceTests.cs b/Govor.Application.Tests/Services/Authentication/AuthServiceTests.cs index 389be9a..5964ad8 100644 --- a/Govor.Application.Tests/Services/Authentication/AuthServiceTests.cs +++ b/Govor.Application.Tests/Services/Authentication/AuthServiceTests.cs @@ -7,6 +7,7 @@ using Govor.Application.Exceptions.AuthService; using Govor.Application.Interfaces.Authentication; using Govor.Application.Services; using Govor.Application.Services.Authentication; +using Govor.Core.Models.Users; using Govor.Core.Repositories.Admins; using Moq; diff --git a/Govor.Application.Tests/Services/Authentication/JwtServiceTests.cs b/Govor.Application.Tests/Services/Authentication/JwtServiceTests.cs index 3fa1585..d7a2889 100644 --- a/Govor.Application.Tests/Services/Authentication/JwtServiceTests.cs +++ b/Govor.Application.Tests/Services/Authentication/JwtServiceTests.cs @@ -1,9 +1,9 @@ using System.IdentityModel.Tokens.Jwt; using AutoFixture; using Govor.API.Services.Authentication.Interfaces; -using Govor.Application.Services; +using Govor.Application.Interfaces.Authentication; using Govor.Application.Services.Authentication; -using Govor.Core.Models; +using Govor.Core.Models.Users; using Microsoft.Extensions.Options; using Moq; diff --git a/Govor.Application.Tests/Services/Friends/FriendRequestQueryServiceTests.cs b/Govor.Application.Tests/Services/Friends/FriendRequestQueryServiceTests.cs index 3cade62..56029ec 100644 --- a/Govor.Application.Tests/Services/Friends/FriendRequestQueryServiceTests.cs +++ b/Govor.Application.Tests/Services/Friends/FriendRequestQueryServiceTests.cs @@ -2,6 +2,7 @@ using AutoFixture; using Govor.Application.Interfaces.Friends; using Govor.Application.Services.Friends; using Govor.Core.Models; +using Govor.Core.Models.Users; using Govor.Core.Repositories.Friendships; using Govor.Core.Repositories.Users; using Govor.Data.Repositories.Exceptions; diff --git a/Govor.Application.Tests/Services/Friends/FriendshipServiceTests.cs b/Govor.Application.Tests/Services/Friends/FriendshipServiceTests.cs index 6a11e56..df8d52d 100644 --- a/Govor.Application.Tests/Services/Friends/FriendshipServiceTests.cs +++ b/Govor.Application.Tests/Services/Friends/FriendshipServiceTests.cs @@ -3,6 +3,7 @@ using Govor.Application.Exceptions.FriendsService; using Govor.Application.Interfaces.Friends; using Govor.Application.Services.Friends; using Govor.Core.Models; +using Govor.Core.Models.Users; using Govor.Core.Repositories.Friendships; using Govor.Core.Repositories.Users; using Govor.Data.Repositories.Exceptions; diff --git a/Govor.Application.Tests/Services/Messages/MessageCommandServiceTests.cs b/Govor.Application.Tests/Services/Messages/MessageCommandServiceTests.cs index f5abc7b..8cccb45 100644 --- a/Govor.Application.Tests/Services/Messages/MessageCommandServiceTests.cs +++ b/Govor.Application.Tests/Services/Messages/MessageCommandServiceTests.cs @@ -3,6 +3,7 @@ using Govor.Application.Interfaces; using Govor.Application.Interfaces.Messages.Parameters; using Govor.Application.Services.Messages; using Govor.Core.Models; +using Govor.Core.Models.Messages; using Govor.Core.Repositories.Groups; using Govor.Core.Repositories.Messages; using Govor.Core.Repositories.PrivateChats; @@ -296,7 +297,7 @@ public class MessageCommandServiceTests Assert.That(result, Is.Not.Null); Assert.That(result.IsSuccess, Is.False); Assert.That(result.Exception, Is.Not.Null); - Assert.That(result.Exception,Is.TypeOf>()); + Assert.That(result.Exception,Is.TypeOf()); Assert.That(result.OriginalMessage, Is.Null); } diff --git a/Govor.Application.Tests/Services/PingHandlerServiceTests.cs b/Govor.Application.Tests/Services/PingHandlerServiceTests.cs index 44ba86b..d59e6ec 100644 --- a/Govor.Application.Tests/Services/PingHandlerServiceTests.cs +++ b/Govor.Application.Tests/Services/PingHandlerServiceTests.cs @@ -1,5 +1,5 @@ using Govor.Application.Services; -using Govor.Core.Models; +using Govor.Core.Models.Users; using Govor.Data; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; diff --git a/Govor.Application/Infrastructure/AdminsStuff/UsersService.cs b/Govor.Application/Infrastructure/AdminsStuff/UsersService.cs index 56044cb..4651283 100644 --- a/Govor.Application/Infrastructure/AdminsStuff/UsersService.cs +++ b/Govor.Application/Infrastructure/AdminsStuff/UsersService.cs @@ -1,9 +1,9 @@ -using Govor.API.Services.AdminsStuff.Interfaces; -using Govor.Core.Models; +using Govor.Application.Interfaces; +using Govor.Core.Models.Users; using Govor.Core.Repositories.Users; using Govor.Data.Repositories.Exceptions; -namespace Govor.Application.Interfaces.AdminsStuff; +namespace Govor.Application.Infrastructure.AdminsStuff; public class UsersService : IUsersAdministration { diff --git a/Govor.Application/Interfaces/Authentication/IInvitesService.cs b/Govor.Application/Interfaces/Authentication/IInvitesService.cs index ba5a9c9..566f145 100644 --- a/Govor.Application/Interfaces/Authentication/IInvitesService.cs +++ b/Govor.Application/Interfaces/Authentication/IInvitesService.cs @@ -1,4 +1,5 @@ using Govor.Core.Models; +using Govor.Core.Models.Users; namespace Govor.API.Services.Authentication.Interfaces; diff --git a/Govor.Application/Interfaces/Authentication/IJwtService.cs b/Govor.Application/Interfaces/Authentication/IJwtService.cs index 7f0bf6b..6904693 100644 --- a/Govor.Application/Interfaces/Authentication/IJwtService.cs +++ b/Govor.Application/Interfaces/Authentication/IJwtService.cs @@ -1,6 +1,6 @@ -using Govor.Core.Models; +using Govor.Core.Models.Users; -namespace Govor.API.Services.Authentication.Interfaces; +namespace Govor.Application.Interfaces.Authentication; public interface IJwtService { diff --git a/Govor.Application/Interfaces/Friends/IFriendshipService.cs b/Govor.Application/Interfaces/Friends/IFriendshipService.cs index c791143..e3d5a7c 100644 --- a/Govor.Application/Interfaces/Friends/IFriendshipService.cs +++ b/Govor.Application/Interfaces/Friends/IFriendshipService.cs @@ -1,4 +1,4 @@ -using Govor.Core.Models; +using Govor.Core.Models.Users; namespace Govor.Application.Interfaces.Friends; diff --git a/Govor.Application/Interfaces/IFriendsService.cs b/Govor.Application/Interfaces/IFriendsService.cs index 9cd4f71..4933f6f 100644 --- a/Govor.Application/Interfaces/IFriendsService.cs +++ b/Govor.Application/Interfaces/IFriendsService.cs @@ -1,4 +1,5 @@ using Govor.Core.Models; +using Govor.Core.Models.Users; namespace Govor.Application.Interfaces; diff --git a/Govor.Application/Interfaces/IGroupService.cs b/Govor.Application/Interfaces/IGroupService.cs index e57119c..c3a8a40 100644 --- a/Govor.Application/Interfaces/IGroupService.cs +++ b/Govor.Application/Interfaces/IGroupService.cs @@ -1,5 +1,6 @@ using Govor.Application.Interfaces.Messages.Parameters; using Govor.Core.Models; +using Govor.Core.Models.Users; namespace Govor.Application.Interfaces; diff --git a/Govor.Application/Interfaces/IMessagesLoader.cs b/Govor.Application/Interfaces/IMessagesLoader.cs index 1cc3334..cb8880d 100644 --- a/Govor.Application/Interfaces/IMessagesLoader.cs +++ b/Govor.Application/Interfaces/IMessagesLoader.cs @@ -1,4 +1,4 @@ -using Govor.Core.Models; +using Govor.Core.Models.Messages; namespace Govor.Application.Interfaces; diff --git a/Govor.Application/Interfaces/IUsersAdministration.cs b/Govor.Application/Interfaces/IUsersAdministration.cs index a6ccae5..dfdd34e 100644 --- a/Govor.Application/Interfaces/IUsersAdministration.cs +++ b/Govor.Application/Interfaces/IUsersAdministration.cs @@ -1,6 +1,6 @@ -using Govor.Core.Models; +using Govor.Core.Models.Users; -namespace Govor.API.Services.AdminsStuff.Interfaces; +namespace Govor.Application.Interfaces; public interface IUsersAdministration { diff --git a/Govor.Application/Interfaces/Medias/IMediaService.cs b/Govor.Application/Interfaces/Medias/IMediaService.cs index ec3e707..6b0e3b3 100644 --- a/Govor.Application/Interfaces/Medias/IMediaService.cs +++ b/Govor.Application/Interfaces/Medias/IMediaService.cs @@ -1,4 +1,4 @@ -using Govor.Core.Models; +using Govor.Core.Models.Messages; namespace Govor.Application.Interfaces.Medias; diff --git a/Govor.Application/Interfaces/Messages/IMessageCommandService.cs b/Govor.Application/Interfaces/Messages/IMessageCommandService.cs index a089c16..e38aa15 100644 --- a/Govor.Application/Interfaces/Messages/IMessageCommandService.cs +++ b/Govor.Application/Interfaces/Messages/IMessageCommandService.cs @@ -1,5 +1,5 @@ using Govor.Application.Interfaces.Messages.Parameters; -using Govor.Core.Models; +using Govor.Core.Models.Messages; namespace Govor.Application.Interfaces.Messages; diff --git a/Govor.Application/Interfaces/Messages/Parameters/SendMessage.cs b/Govor.Application/Interfaces/Messages/Parameters/SendMessage.cs index f59e222..3f68d10 100644 --- a/Govor.Application/Interfaces/Messages/Parameters/SendMessage.cs +++ b/Govor.Application/Interfaces/Messages/Parameters/SendMessage.cs @@ -1,4 +1,4 @@ -using Govor.Core.Models; +using Govor.Core.Models.Messages; namespace Govor.Application.Interfaces.Messages.Parameters; diff --git a/Govor.Application/Services/Authentication/AuthService.cs b/Govor.Application/Services/Authentication/AuthService.cs index 96685b8..dbfcf01 100644 --- a/Govor.Application/Services/Authentication/AuthService.cs +++ b/Govor.Application/Services/Authentication/AuthService.cs @@ -1,11 +1,9 @@ -using System.Text.RegularExpressions; -using Govor.API.Services.Authentication.Interfaces; using Govor.Application.Exceptions.AuthService; using Govor.Core.Infrastructure.Extensions; using Govor.Core.Models; using Govor.Core.Repositories.Users; using Govor.Application.Interfaces.Authentication; -using Govor.Core.Infrastructure.Validators; +using Govor.Core.Models.Users; using Govor.Core.Repositories.Admins; namespace Govor.Application.Services.Authentication; diff --git a/Govor.Application/Services/Authentication/JwtService.cs b/Govor.Application/Services/Authentication/JwtService.cs index 56cf08c..9f5e8e4 100644 --- a/Govor.Application/Services/Authentication/JwtService.cs +++ b/Govor.Application/Services/Authentication/JwtService.cs @@ -2,7 +2,8 @@ using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using Govor.API.Services.Authentication.Interfaces; -using Govor.Core.Models; +using Govor.Application.Interfaces.Authentication; +using Govor.Core.Models.Users; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; diff --git a/Govor.Application/Services/Friends/FriendshipService.cs b/Govor.Application/Services/Friends/FriendshipService.cs index 522b2f5..a6bfa9f 100644 --- a/Govor.Application/Services/Friends/FriendshipService.cs +++ b/Govor.Application/Services/Friends/FriendshipService.cs @@ -1,6 +1,7 @@ using Govor.Application.Exceptions.FriendsService; using Govor.Application.Interfaces.Friends; using Govor.Core.Models; +using Govor.Core.Models.Users; using Govor.Core.Repositories.Friendships; using Govor.Core.Repositories.Users; using Govor.Data.Repositories.Exceptions; diff --git a/Govor.Application/Services/InvitesService.cs b/Govor.Application/Services/InvitesService.cs index 07b4f49..0a5a240 100644 --- a/Govor.Application/Services/InvitesService.cs +++ b/Govor.Application/Services/InvitesService.cs @@ -1,6 +1,7 @@ using Govor.API.Services.Authentication.Interfaces; using Govor.Application.Exceptions.InvitesService; using Govor.Core.Models; +using Govor.Core.Models.Users; using Govor.Core.Repositories.Invaites; using Govor.Data.Repositories.Exceptions; diff --git a/Govor.Application/Services/Messages/MessageCommandService.cs b/Govor.Application/Services/Messages/MessageCommandService.cs index 590ee52..09b59d1 100644 --- a/Govor.Application/Services/Messages/MessageCommandService.cs +++ b/Govor.Application/Services/Messages/MessageCommandService.cs @@ -2,10 +2,12 @@ using Govor.Application.Interfaces; using Govor.Application.Interfaces.Messages; using Govor.Application.Interfaces.Messages.Parameters; using Govor.Core.Models; +using Govor.Core.Models.Messages; using Govor.Core.Repositories.Groups; using Govor.Core.Repositories.Messages; using Govor.Core.Repositories.PrivateChats; using Govor.Core.Repositories.Users; +using Govor.Data.Repositories.Exceptions; using Microsoft.Extensions.Logging; namespace Govor.Application.Services.Messages; @@ -183,11 +185,14 @@ public class MessageCommandService : IMessageCommandService // return new DeleteMessageResult(false, new UnauthorizedAccessException("User is not authorized to delete this message."), null); // } // } else { - _logger.LogWarning("User {DeleterId} attempted to delete message {MessageId} not sent by them (sender was {SenderId})", deleteParams.DeleterId, deleteParams.MessageId, message.SenderId); - return new DeleteMessageResult(false, new UnauthorizedAccessException("User is not authorized to delete this message."), default); + _logger.LogWarning( + "User {DeleterId} attempted to delete message {MessageId} not sent by them (sender was {SenderId})", + deleteParams.DeleterId, deleteParams.MessageId, message.SenderId); + return new DeleteMessageResult(false, + new UnauthorizedAccessException("User is not authorized to delete this message."), default); // } } - + var originalMessageForNotification = new Message { Id = message.Id, @@ -195,12 +200,17 @@ public class MessageCommandService : IMessageCommandService RecipientId = message.RecipientId, RecipientType = message.RecipientType, }; - + await _messagesRepository.RemoveAsync(deleteParams.MessageId); - - _logger.LogInformation("Message {MessageId} deleted successfully by user {DeleterId}", deleteParams.MessageId, deleteParams.DeleterId); + + _logger.LogInformation("Message {MessageId} deleted successfully by user {DeleterId}", + deleteParams.MessageId, deleteParams.DeleterId); return new DeleteMessageResult(true, default, originalMessageForNotification); } + catch (NotFoundByKeyException ex) + { + return new DeleteMessageResult(false, new KeyNotFoundException("Message not found", ex), default); + } catch (Exception ex) { _logger.LogError(ex, "Error deleting message {MessageId} by user {DeleterId}", deleteParams.MessageId, deleteParams.DeleterId); diff --git a/Govor.Application/Services/Messages/MessagesLoader.cs b/Govor.Application/Services/Messages/MessagesLoader.cs index fc4cca5..3734002 100644 --- a/Govor.Application/Services/Messages/MessagesLoader.cs +++ b/Govor.Application/Services/Messages/MessagesLoader.cs @@ -1,5 +1,5 @@ using Govor.Application.Interfaces; -using Govor.Core.Models; +using Govor.Core.Models.Messages; using Govor.Core.Repositories.Groups; using Govor.Core.Repositories.Messages; using Govor.Data.Repositories.Exceptions; diff --git a/Govor.Console/Program.cs b/Govor.Console/Program.cs index 4609304..bc21795 100644 --- a/Govor.Console/Program.cs +++ b/Govor.Console/Program.cs @@ -10,6 +10,7 @@ using Govor.ConsoleClient.Commands; using Govor.Contracts.Requests.SignalR; using Govor.Contracts.Responses.SignalR; using Govor.Core.Models; +using Govor.Core.Models.Messages; /*==================================== *Личные сообщения| Егор @@ -200,7 +201,7 @@ namespace Govor.ConsoleClient options.AccessTokenProvider = () => Task.FromResult(AuthToken); }) .Build(); - + _hubConnection.On("ReceiveMessage", (message) => { var myId = GetMyUserId(); diff --git a/Govor.Contracts/Requests/MediaUploadRequest.cs b/Govor.Contracts/Requests/MediaUploadRequest.cs index 73f2c20..4408dc1 100644 --- a/Govor.Contracts/Requests/MediaUploadRequest.cs +++ b/Govor.Contracts/Requests/MediaUploadRequest.cs @@ -1,5 +1,4 @@ -using System.ComponentModel.DataAnnotations; -using Govor.Core.Models; +using Govor.Core.Models.Messages; namespace Govor.Contracts.Requests; diff --git a/Govor.Contracts/Requests/SignalR/MessageRequest.cs b/Govor.Contracts/Requests/SignalR/MessageRequest.cs index fdce57f..a29e3fb 100644 --- a/Govor.Contracts/Requests/SignalR/MessageRequest.cs +++ b/Govor.Contracts/Requests/SignalR/MessageRequest.cs @@ -1,4 +1,4 @@ -using Govor.Core.Models; +using Govor.Core.Models.Messages; namespace Govor.Contracts.Requests.SignalR; diff --git a/Govor.Contracts/Responses/SignalR/MessageEditResponse.cs b/Govor.Contracts/Responses/SignalR/MessageEditResponse.cs new file mode 100644 index 0000000..a82ecca --- /dev/null +++ b/Govor.Contracts/Responses/SignalR/MessageEditResponse.cs @@ -0,0 +1,6 @@ +namespace Govor.Contracts.Responses.SignalR; + +public class MessageEditResponse +{ + +} \ No newline at end of file diff --git a/Govor.Contracts/Responses/SignalR/MessageRemovedResponse.cs b/Govor.Contracts/Responses/SignalR/MessageRemovedResponse.cs index 2fd3ff8..89471b4 100644 --- a/Govor.Contracts/Responses/SignalR/MessageRemovedResponse.cs +++ b/Govor.Contracts/Responses/SignalR/MessageRemovedResponse.cs @@ -1,4 +1,4 @@ -using Govor.Core.Models; +using Govor.Core.Models.Messages; namespace Govor.Contracts.Responses.SignalR; diff --git a/Govor.Contracts/Responses/SignalR/UserMessageResponse.cs b/Govor.Contracts/Responses/SignalR/UserMessageResponse.cs index 20a33fd..6632c60 100644 --- a/Govor.Contracts/Responses/SignalR/UserMessageResponse.cs +++ b/Govor.Contracts/Responses/SignalR/UserMessageResponse.cs @@ -1,5 +1,5 @@ -using Govor.Contracts.Requests.SignalR; using Govor.Core.Models; +using Govor.Core.Models.Messages; namespace Govor.Contracts.Responses.SignalR; diff --git a/Govor.Core/Infrastructure/Validators/AdminValidator.cs b/Govor.Core/Infrastructure/Validators/AdminValidator.cs index 4c5acc5..2aebfb6 100644 --- a/Govor.Core/Infrastructure/Validators/AdminValidator.cs +++ b/Govor.Core/Infrastructure/Validators/AdminValidator.cs @@ -1,4 +1,4 @@ -using Govor.Core.Models; +using Govor.Core.Models.Users; namespace Govor.Core.Infrastructure.Validators; diff --git a/Govor.Core/Infrastructure/Validators/MediaAttachmentsValidator.cs b/Govor.Core/Infrastructure/Validators/MediaAttachmentsValidator.cs index aa598f7..6a2bd1c 100644 --- a/Govor.Core/Infrastructure/Validators/MediaAttachmentsValidator.cs +++ b/Govor.Core/Infrastructure/Validators/MediaAttachmentsValidator.cs @@ -1,4 +1,4 @@ -using Govor.Core.Models; +using Govor.Core.Models.Messages; using Exception = System.Exception; namespace Govor.Core.Infrastructure.Validators; diff --git a/Govor.Core/Infrastructure/Validators/MessageValidator.cs b/Govor.Core/Infrastructure/Validators/MessageValidator.cs index fbe5530..149ac1d 100644 --- a/Govor.Core/Infrastructure/Validators/MessageValidator.cs +++ b/Govor.Core/Infrastructure/Validators/MessageValidator.cs @@ -1,4 +1,4 @@ -using Govor.Core.Models; +using Govor.Core.Models.Messages; namespace Govor.Core.Infrastructure.Validators; diff --git a/Govor.Core/Infrastructure/Validators/UserValidator.cs b/Govor.Core/Infrastructure/Validators/UserValidator.cs index 6bcc609..b849aca 100644 --- a/Govor.Core/Infrastructure/Validators/UserValidator.cs +++ b/Govor.Core/Infrastructure/Validators/UserValidator.cs @@ -1,4 +1,4 @@ -using Govor.Core.Models; +using Govor.Core.Models.Users; using ArgumentNullException = System.ArgumentNullException; namespace Govor.Core.Infrastructure.Validators; diff --git a/Govor.Core/Models/Friendship.cs b/Govor.Core/Models/Friendship.cs index 6a7fe25..442bf37 100644 --- a/Govor.Core/Models/Friendship.cs +++ b/Govor.Core/Models/Friendship.cs @@ -1,3 +1,5 @@ +using Govor.Core.Models.Users; + namespace Govor.Core.Models; public class Friendship diff --git a/Govor.Core/Models/GroupInvitation.cs b/Govor.Core/Models/GroupInvitation.cs index 57f44c9..3f135fd 100644 --- a/Govor.Core/Models/GroupInvitation.cs +++ b/Govor.Core/Models/GroupInvitation.cs @@ -1,3 +1,5 @@ +using Govor.Core.Models.Users; + namespace Govor.Core.Models; public class GroupInvitation diff --git a/Govor.Core/Models/Invitation.cs b/Govor.Core/Models/Invitation.cs index adb0d90..46e0555 100644 --- a/Govor.Core/Models/Invitation.cs +++ b/Govor.Core/Models/Invitation.cs @@ -1,3 +1,5 @@ +using Govor.Core.Models.Users; + namespace Govor.Core.Models; public class Invitation diff --git a/Govor.Core/Models/MediaFile.cs b/Govor.Core/Models/MediaFile.cs index 038f00f..6023416 100644 --- a/Govor.Core/Models/MediaFile.cs +++ b/Govor.Core/Models/MediaFile.cs @@ -1,3 +1,5 @@ +using Govor.Core.Models.Messages; + namespace Govor.Core.Models; public class MediaFile diff --git a/Govor.Core/Models/MediaAttachments.cs b/Govor.Core/Models/Messages/MediaAttachments.cs similarity index 93% rename from Govor.Core/Models/MediaAttachments.cs rename to Govor.Core/Models/Messages/MediaAttachments.cs index 947d458..99a5a6b 100644 --- a/Govor.Core/Models/MediaAttachments.cs +++ b/Govor.Core/Models/Messages/MediaAttachments.cs @@ -1,4 +1,4 @@ -namespace Govor.Core.Models; +namespace Govor.Core.Models.Messages; public class MediaAttachments { diff --git a/Govor.Core/Models/Message.cs b/Govor.Core/Models/Messages/Message.cs similarity index 97% rename from Govor.Core/Models/Message.cs rename to Govor.Core/Models/Messages/Message.cs index 6b72852..d999f4b 100644 --- a/Govor.Core/Models/Message.cs +++ b/Govor.Core/Models/Messages/Message.cs @@ -1,4 +1,4 @@ -namespace Govor.Core.Models; +namespace Govor.Core.Models.Messages; public class Message { diff --git a/Govor.Core/Models/MessageReaction.cs b/Govor.Core/Models/Messages/MessageReaction.cs similarity index 84% rename from Govor.Core/Models/MessageReaction.cs rename to Govor.Core/Models/Messages/MessageReaction.cs index 8e8fa80..1ce2382 100644 --- a/Govor.Core/Models/MessageReaction.cs +++ b/Govor.Core/Models/Messages/MessageReaction.cs @@ -1,4 +1,6 @@ -namespace Govor.Core.Models; +using Govor.Core.Models.Users; + +namespace Govor.Core.Models.Messages; public class MessageReaction { diff --git a/Govor.Core/Models/MessageView.cs b/Govor.Core/Models/Messages/MessageView.cs similarity index 82% rename from Govor.Core/Models/MessageView.cs rename to Govor.Core/Models/Messages/MessageView.cs index 8f09035..0bb266a 100644 --- a/Govor.Core/Models/MessageView.cs +++ b/Govor.Core/Models/Messages/MessageView.cs @@ -1,4 +1,4 @@ -namespace Govor.Core.Models; +namespace Govor.Core.Models.Messages; public class MessageView { diff --git a/Govor.Core/Models/PrivateChat.cs b/Govor.Core/Models/PrivateChat.cs index 51f0921..df8cc29 100644 --- a/Govor.Core/Models/PrivateChat.cs +++ b/Govor.Core/Models/PrivateChat.cs @@ -1,3 +1,5 @@ +using Govor.Core.Models.Messages; + namespace Govor.Core.Models; public class PrivateChat diff --git a/Govor.Core/Models/Admin.cs b/Govor.Core/Models/Users/Admin.cs similarity index 82% rename from Govor.Core/Models/Admin.cs rename to Govor.Core/Models/Users/Admin.cs index a0ef8ca..c3e20bf 100644 --- a/Govor.Core/Models/Admin.cs +++ b/Govor.Core/Models/Users/Admin.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; -namespace Govor.Core.Models; +namespace Govor.Core.Models.Users; public class Admin { diff --git a/Govor.Core/Models/Users/PrivacyUserSettings.cs b/Govor.Core/Models/Users/PrivacyUserSettings.cs new file mode 100644 index 0000000..1fece99 --- /dev/null +++ b/Govor.Core/Models/Users/PrivacyUserSettings.cs @@ -0,0 +1,39 @@ +namespace Govor.Core.Models.Users; + +public class PrivacyUserSettings +{ + public Guid UserId { get; set; } + public bool IsGlobalAccount { get; set; } + + public WhoCan CanSend { get; set; } + public List? WhitelistSent { get; set; } + public List? BlacklistSent { get; set; } + + public WhoCan CanSeeTimeWas { get; set; } + public List? WhitelistTimeWas { get; set; } + public List? BlacklistTimeWas { get; set; } + + public WhoCan CanSeeImage { get; set; } + public List? WhitelistSeeImage { get; set; } + public List? BlacklistSeeImage{ get; set; } + + public DeletingMessagesVia Via { get; set; } // if min value = none + public int DeletingIn { get; set; } +} + +public enum WhoCan +{ + None = 0, + OnlyFriends = 1, + EveryoneCanSend = 2, +} + +public enum DeletingMessagesVia +{ + None = 0, + Hours = 1, + Days = 2, + Months = 3, + Years = 4 +} + diff --git a/Govor.Core/Models/User.cs b/Govor.Core/Models/Users/User.cs similarity index 97% rename from Govor.Core/Models/User.cs rename to Govor.Core/Models/Users/User.cs index 616d9d8..0669a69 100644 --- a/Govor.Core/Models/User.cs +++ b/Govor.Core/Models/Users/User.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; -namespace Govor.Core.Models; +namespace Govor.Core.Models.Users; public class User { diff --git a/Govor.Core/Repositories/Admins/IAdminsExist.cs b/Govor.Core/Repositories/Admins/IAdminsExist.cs index c847400..c04e928 100644 --- a/Govor.Core/Repositories/Admins/IAdminsExist.cs +++ b/Govor.Core/Repositories/Admins/IAdminsExist.cs @@ -1,4 +1,4 @@ -using Govor.Core.Models; +using Govor.Core.Models.Users; namespace Govor.Core.Repositories.Admins; diff --git a/Govor.Core/Repositories/Admins/IAdminsReader.cs b/Govor.Core/Repositories/Admins/IAdminsReader.cs index b5d01af..ca550a8 100644 --- a/Govor.Core/Repositories/Admins/IAdminsReader.cs +++ b/Govor.Core/Repositories/Admins/IAdminsReader.cs @@ -1,4 +1,4 @@ -using Govor.Core.Models; +using Govor.Core.Models.Users; namespace Govor.Core.Repositories.Admins; diff --git a/Govor.Core/Repositories/Admins/IAdminsWriter.cs b/Govor.Core/Repositories/Admins/IAdminsWriter.cs index d34d582..60cbe2a 100644 --- a/Govor.Core/Repositories/Admins/IAdminsWriter.cs +++ b/Govor.Core/Repositories/Admins/IAdminsWriter.cs @@ -1,4 +1,4 @@ -using Govor.Core.Models; +using Govor.Core.Models.Users; namespace Govor.Core.Repositories.Admins; diff --git a/Govor.Core/Repositories/Groups/IGroupMessagesReader.cs b/Govor.Core/Repositories/Groups/IGroupMessagesReader.cs index aed1ffc..d53b8ce 100644 --- a/Govor.Core/Repositories/Groups/IGroupMessagesReader.cs +++ b/Govor.Core/Repositories/Groups/IGroupMessagesReader.cs @@ -1,4 +1,4 @@ -using Govor.Core.Models; +using Govor.Core.Models.Messages; namespace Govor.Core.Repositories.Groups; diff --git a/Govor.Core/Repositories/MediasAttachments/IMediaAttachmentsExist.cs b/Govor.Core/Repositories/MediasAttachments/IMediaAttachmentsExist.cs index efca3bd..89ab2d7 100644 --- a/Govor.Core/Repositories/MediasAttachments/IMediaAttachmentsExist.cs +++ b/Govor.Core/Repositories/MediasAttachments/IMediaAttachmentsExist.cs @@ -1,4 +1,4 @@ -using Govor.Core.Models; +using Govor.Core.Models.Messages; namespace Govor.Core.Repositories.MediasAttachments; diff --git a/Govor.Core/Repositories/MediasAttachments/IMediaAttachmentsReader.cs b/Govor.Core/Repositories/MediasAttachments/IMediaAttachmentsReader.cs index 9a6f201..cb54728 100644 --- a/Govor.Core/Repositories/MediasAttachments/IMediaAttachmentsReader.cs +++ b/Govor.Core/Repositories/MediasAttachments/IMediaAttachmentsReader.cs @@ -1,4 +1,4 @@ -using Govor.Core.Models; +using Govor.Core.Models.Messages; namespace Govor.Core.Repositories.MediasAttachments; diff --git a/Govor.Core/Repositories/MediasAttachments/IMediaAttachmentsWriter.cs b/Govor.Core/Repositories/MediasAttachments/IMediaAttachmentsWriter.cs index fbed08b..5754a3f 100644 --- a/Govor.Core/Repositories/MediasAttachments/IMediaAttachmentsWriter.cs +++ b/Govor.Core/Repositories/MediasAttachments/IMediaAttachmentsWriter.cs @@ -1,4 +1,4 @@ -using Govor.Core.Models; +using Govor.Core.Models.Messages; namespace Govor.Core.Repositories.MediasAttachments; diff --git a/Govor.Core/Repositories/Messages/IMessagesExist.cs b/Govor.Core/Repositories/Messages/IMessagesExist.cs index 9bb0833..6d6deb6 100644 --- a/Govor.Core/Repositories/Messages/IMessagesExist.cs +++ b/Govor.Core/Repositories/Messages/IMessagesExist.cs @@ -1,4 +1,4 @@ -using Govor.Core.Models; +using Govor.Core.Models.Messages; namespace Govor.Core.Repositories.Messages; diff --git a/Govor.Core/Repositories/Messages/IMessagesReader.cs b/Govor.Core/Repositories/Messages/IMessagesReader.cs index 623256c..5ca6afc 100644 --- a/Govor.Core/Repositories/Messages/IMessagesReader.cs +++ b/Govor.Core/Repositories/Messages/IMessagesReader.cs @@ -1,4 +1,4 @@ -using Govor.Core.Models; +using Govor.Core.Models.Messages; namespace Govor.Core.Repositories.Messages; diff --git a/Govor.Core/Repositories/Messages/IMessagesWriter.cs b/Govor.Core/Repositories/Messages/IMessagesWriter.cs index d316bbf..f9f7b81 100644 --- a/Govor.Core/Repositories/Messages/IMessagesWriter.cs +++ b/Govor.Core/Repositories/Messages/IMessagesWriter.cs @@ -1,4 +1,4 @@ -using Govor.Core.Models; +using Govor.Core.Models.Messages; namespace Govor.Core.Repositories.Messages; diff --git a/Govor.Core/Repositories/Users/IUsersExist.cs b/Govor.Core/Repositories/Users/IUsersExist.cs index 19f2038..a06a180 100644 --- a/Govor.Core/Repositories/Users/IUsersExist.cs +++ b/Govor.Core/Repositories/Users/IUsersExist.cs @@ -1,4 +1,4 @@ -using Govor.Core.Models; +using Govor.Core.Models.Users; namespace Govor.Core.Repositories.Users; diff --git a/Govor.Core/Repositories/Users/IUsersReader.cs b/Govor.Core/Repositories/Users/IUsersReader.cs index 277c906..53c4a32 100644 --- a/Govor.Core/Repositories/Users/IUsersReader.cs +++ b/Govor.Core/Repositories/Users/IUsersReader.cs @@ -1,4 +1,4 @@ -using Govor.Core.Models; +using Govor.Core.Models.Users; namespace Govor.Core.Repositories.Users; diff --git a/Govor.Core/Repositories/Users/IUsersWriter.cs b/Govor.Core/Repositories/Users/IUsersWriter.cs index edfcbe9..d05c8cf 100644 --- a/Govor.Core/Repositories/Users/IUsersWriter.cs +++ b/Govor.Core/Repositories/Users/IUsersWriter.cs @@ -1,4 +1,4 @@ -using Govor.Core.Models; +using Govor.Core.Models.Users; namespace Govor.Core.Repositories.Users; diff --git a/Govor.Data.Tests/Repositories/AdminsRepositoryTests.cs b/Govor.Data.Tests/Repositories/AdminsRepositoryTests.cs index ed6b383..5e95ea6 100644 --- a/Govor.Data.Tests/Repositories/AdminsRepositoryTests.cs +++ b/Govor.Data.Tests/Repositories/AdminsRepositoryTests.cs @@ -1,6 +1,7 @@ using AutoFixture; using Govor.Core.Infrastructure.Validators; using Govor.Core.Models; +using Govor.Core.Models.Users; using Govor.Data; using Govor.Data.Repositories; using Govor.Data.Repositories.Exceptions; diff --git a/Govor.Data.Tests/Repositories/MediaAttachmentsTests.cs b/Govor.Data.Tests/Repositories/MediaAttachmentsTests.cs index 54331a6..232b62c 100644 --- a/Govor.Data.Tests/Repositories/MediaAttachmentsTests.cs +++ b/Govor.Data.Tests/Repositories/MediaAttachmentsTests.cs @@ -1,6 +1,7 @@ using AutoFixture; using Govor.Core.Infrastructure.Validators; using Govor.Core.Models; +using Govor.Core.Models.Messages; using Govor.Data; using Govor.Data.Repositories; using Govor.Data.Repositories.Exceptions; diff --git a/Govor.Data.Tests/Repositories/MessagesRepositoryTests.cs b/Govor.Data.Tests/Repositories/MessagesRepositoryTests.cs index e0f60e2..7c4a144 100644 --- a/Govor.Data.Tests/Repositories/MessagesRepositoryTests.cs +++ b/Govor.Data.Tests/Repositories/MessagesRepositoryTests.cs @@ -1,6 +1,7 @@ using AutoFixture; using Govor.Core.Infrastructure.Validators; using Govor.Core.Models; +using Govor.Core.Models.Messages; using Govor.Data; using Govor.Data.Repositories; using Govor.Data.Repositories.Exceptions; diff --git a/Govor.Data.Tests/Repositories/UsersRepositoryTests.cs b/Govor.Data.Tests/Repositories/UsersRepositoryTests.cs index 6080460..8a37d46 100644 --- a/Govor.Data.Tests/Repositories/UsersRepositoryTests.cs +++ b/Govor.Data.Tests/Repositories/UsersRepositoryTests.cs @@ -1,6 +1,7 @@ using AutoFixture; using Govor.Core.Infrastructure.Validators; using Govor.Core.Models; +using Govor.Core.Models.Users; using Govor.Data; using Govor.Data.Repositories; using Govor.Data.Repositories.Exceptions; diff --git a/Govor.Data/Configurations/AdminConfiguration.cs b/Govor.Data/Configurations/AdminConfiguration.cs index 6d4190f..7f0dfad 100644 --- a/Govor.Data/Configurations/AdminConfiguration.cs +++ b/Govor.Data/Configurations/AdminConfiguration.cs @@ -1,4 +1,4 @@ -using Govor.Core.Models; +using Govor.Core.Models.Users; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; diff --git a/Govor.Data/Configurations/MediaAttachmentsConfiguration.cs b/Govor.Data/Configurations/MediaAttachmentsConfiguration.cs index b0ad220..26fcca3 100644 --- a/Govor.Data/Configurations/MediaAttachmentsConfiguration.cs +++ b/Govor.Data/Configurations/MediaAttachmentsConfiguration.cs @@ -1,4 +1,4 @@ -using Govor.Core.Models; +using Govor.Core.Models.Messages; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; diff --git a/Govor.Data/Configurations/MessageReactionConfiguration.cs b/Govor.Data/Configurations/MessageReactionConfiguration.cs index 90080c3..77ca4f2 100644 --- a/Govor.Data/Configurations/MessageReactionConfiguration.cs +++ b/Govor.Data/Configurations/MessageReactionConfiguration.cs @@ -1,4 +1,4 @@ -using Govor.Core.Models; +using Govor.Core.Models.Messages; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; diff --git a/Govor.Data/Configurations/MessageViewConfiguration.cs b/Govor.Data/Configurations/MessageViewConfiguration.cs index 7758abc..04d5774 100644 --- a/Govor.Data/Configurations/MessageViewConfiguration.cs +++ b/Govor.Data/Configurations/MessageViewConfiguration.cs @@ -1,4 +1,4 @@ -using Govor.Core.Models; +using Govor.Core.Models.Messages; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; diff --git a/Govor.Data/Configurations/MessagesConfiguration.cs b/Govor.Data/Configurations/MessagesConfiguration.cs index f1a973d..5eee870 100644 --- a/Govor.Data/Configurations/MessagesConfiguration.cs +++ b/Govor.Data/Configurations/MessagesConfiguration.cs @@ -1,4 +1,4 @@ -using Govor.Core.Models; +using Govor.Core.Models.Messages; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; diff --git a/Govor.Data/Configurations/UserConfiguration.cs b/Govor.Data/Configurations/UserConfiguration.cs index 205442d..fb3bfed 100644 --- a/Govor.Data/Configurations/UserConfiguration.cs +++ b/Govor.Data/Configurations/UserConfiguration.cs @@ -1,4 +1,4 @@ -using Govor.Core.Models; +using Govor.Core.Models.Users; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; diff --git a/Govor.Data/GovorDbContext.cs b/Govor.Data/GovorDbContext.cs index 5414310..040314a 100644 --- a/Govor.Data/GovorDbContext.cs +++ b/Govor.Data/GovorDbContext.cs @@ -1,5 +1,7 @@ using System.Text.RegularExpressions; using Govor.Core.Models; +using Govor.Core.Models.Messages; +using Govor.Core.Models.Users; using Govor.Data.Configurations; using Microsoft.EntityFrameworkCore; diff --git a/Govor.Data/Repositories/AdminsRepository.cs b/Govor.Data/Repositories/AdminsRepository.cs index d6a050c..022c7bb 100644 --- a/Govor.Data/Repositories/AdminsRepository.cs +++ b/Govor.Data/Repositories/AdminsRepository.cs @@ -1,6 +1,7 @@ using Govor.Core.Infrastructure.Extensions; using Govor.Core.Infrastructure.Validators; using Govor.Core.Models; +using Govor.Core.Models.Users; using Govor.Core.Repositories.Admins; using Govor.Data.Repositories.Exceptions; using Microsoft.EntityFrameworkCore; diff --git a/Govor.Data/Repositories/FriendshipsRepository.cs b/Govor.Data/Repositories/FriendshipsRepository.cs index 7ccf49b..42d6789 100644 --- a/Govor.Data/Repositories/FriendshipsRepository.cs +++ b/Govor.Data/Repositories/FriendshipsRepository.cs @@ -1,6 +1,7 @@ using Govor.Core.Infrastructure.Extensions; using Govor.Core.Infrastructure.Validators; using Govor.Core.Models; +using Govor.Core.Models.Users; using Govor.Core.Repositories.Friendships; using Govor.Data.Repositories.Exceptions; using Microsoft.EntityFrameworkCore; diff --git a/Govor.Data/Repositories/GroupRepository.cs b/Govor.Data/Repositories/GroupRepository.cs index f765551..6250600 100644 --- a/Govor.Data/Repositories/GroupRepository.cs +++ b/Govor.Data/Repositories/GroupRepository.cs @@ -1,7 +1,7 @@ -using System.Text.RegularExpressions; using Govor.Core.Infrastructure.Extensions; using Govor.Core.Infrastructure.Validators; using Govor.Core.Models; +using Govor.Core.Models.Users; using Govor.Core.Repositories.Groups; using Govor.Data.Repositories.Exceptions; using Microsoft.EntityFrameworkCore; diff --git a/Govor.Data/Repositories/InvitesRepository.cs b/Govor.Data/Repositories/InvitesRepository.cs index 9e2c2f3..86cc7be 100644 --- a/Govor.Data/Repositories/InvitesRepository.cs +++ b/Govor.Data/Repositories/InvitesRepository.cs @@ -1,6 +1,7 @@ using Govor.Core.Infrastructure.Extensions; using Govor.Core.Infrastructure.Validators; using Govor.Core.Models; +using Govor.Core.Models.Users; using Govor.Core.Repositories.Invaites; using Govor.Data.Repositories.Exceptions; using Microsoft.EntityFrameworkCore; diff --git a/Govor.Data/Repositories/MediaAttachmentsRepository.cs b/Govor.Data/Repositories/MediaAttachmentsRepository.cs index e60a3b4..7694691 100644 --- a/Govor.Data/Repositories/MediaAttachmentsRepository.cs +++ b/Govor.Data/Repositories/MediaAttachmentsRepository.cs @@ -1,6 +1,6 @@ using Govor.Core.Infrastructure.Extensions; using Govor.Core.Infrastructure.Validators; -using Govor.Core.Models; +using Govor.Core.Models.Messages; using Govor.Core.Repositories.MediasAttachments; using Govor.Data.Repositories.Exceptions; using Microsoft.EntityFrameworkCore; diff --git a/Govor.Data/Repositories/MessagesRepository.cs b/Govor.Data/Repositories/MessagesRepository.cs index a6c0a16..5f34c85 100644 --- a/Govor.Data/Repositories/MessagesRepository.cs +++ b/Govor.Data/Repositories/MessagesRepository.cs @@ -1,6 +1,7 @@ using Govor.Core.Infrastructure.Extensions; using Govor.Core.Infrastructure.Validators; using Govor.Core.Models; +using Govor.Core.Models.Messages; using Govor.Core.Repositories.Messages; using Govor.Data.Repositories.Exceptions; using Microsoft.EntityFrameworkCore; diff --git a/Govor.Data/Repositories/UsersRepository.cs b/Govor.Data/Repositories/UsersRepository.cs index b08bbb9..55b3519 100644 --- a/Govor.Data/Repositories/UsersRepository.cs +++ b/Govor.Data/Repositories/UsersRepository.cs @@ -1,7 +1,6 @@ using Govor.Core.Infrastructure.Extensions; using Govor.Core.Infrastructure.Validators; -using Govor.Core.Models; -using Govor.Core.Repositories; +using Govor.Core.Models.Users; using Govor.Core.Repositories.Users; using Govor.Data.Repositories.Exceptions; using Microsoft.EntityFrameworkCore; From 96587b491d46662a6538393a1f56bc8522986e0a Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Sat, 12 Jul 2025 16:39:06 +0700 Subject: [PATCH 045/101] Add AutoMapper and message response models Introduced AutoMapper to the API project and registered it in the DI container. Added mapping profiles for core models to DTOs and response objects. Refactored controllers to use AutoMapper for mapping entities to response DTOs. Implemented new response models for messages, media attachments, reactions, and views. Updated MessagesLoader to load messages with related data. Added a migration to support ChatGroupId in messages. Updated dependencies and fixed minor issues in related files. --- Govor.API/Controllers/ChatLoadController.cs | 84 ++- .../Friends/FriendsRequestQueryController.cs | 12 +- .../Friends/FriendshipController.cs | 29 +- .../ConfigurationProgramExtensions.cs | 3 + Govor.API/Extensions/MappingProfile.cs | 22 + Govor.API/Govor.API.csproj | 2 + Govor.API/Hubs/ChatsHub.cs | 19 +- Govor.API/Program.cs | 3 +- .../Services/Messages/MessagesLoader.cs | 53 +- Govor.Console/Program.cs | 10 +- .../Responses/MediaAttachmentResponse.cs | 8 + .../Responses/MessageReactionResponse.cs | 10 + Govor.Contracts/Responses/MessageResponse.cs | 20 + .../Responses/MessageViewResponse.cs | 9 + .../Responses/SignalR/HubResult.cs | 2 +- Govor.Core/Models/ChatGroup.cs | 5 +- Govor.Core/Models/PrivateChat.cs | 2 +- .../PrivateChats/IPrivateChatsRepository.cs | 2 + ...090733_AddChatGroupIdToMessage.Designer.cs | 593 ++++++++++++++++++ .../20250712090733_AddChatGroupIdToMessage.cs | 50 ++ .../Migrations/GovorDbContextModelSnapshot.cs | 131 ++-- .../Repositories/PrivateChatsRepository.cs | 1 + 22 files changed, 972 insertions(+), 98 deletions(-) create mode 100644 Govor.API/Extensions/MappingProfile.cs create mode 100644 Govor.Contracts/Responses/MediaAttachmentResponse.cs create mode 100644 Govor.Contracts/Responses/MessageReactionResponse.cs create mode 100644 Govor.Contracts/Responses/MessageResponse.cs create mode 100644 Govor.Contracts/Responses/MessageViewResponse.cs create mode 100644 Govor.Data/Migrations/20250712090733_AddChatGroupIdToMessage.Designer.cs create mode 100644 Govor.Data/Migrations/20250712090733_AddChatGroupIdToMessage.cs diff --git a/Govor.API/Controllers/ChatLoadController.cs b/Govor.API/Controllers/ChatLoadController.cs index fd9a537..9a18d3c 100644 --- a/Govor.API/Controllers/ChatLoadController.cs +++ b/Govor.API/Controllers/ChatLoadController.cs @@ -1,4 +1,7 @@ +using AutoMapper; using Govor.Application.Interfaces; +using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.Contracts.Responses; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -9,8 +12,85 @@ namespace Govor.API.Controllers; [Route("api/chats")] public class ChatLoadController : Controller { - public ChatLoadController(ILogger logger, IMessagesLoader messagesLoader) + private readonly ICurrentUserService _currentUser; + private readonly ILogger _logger; + private readonly IMessagesLoader _messagesLoader; + private readonly IMapper _mapper; + public ChatLoadController( + ILogger logger, + IMessagesLoader messagesLoader, + ICurrentUserService currentUser, + IMapper mapper) { - + _logger = logger; + _messagesLoader = messagesLoader; + _currentUser = currentUser; + _mapper = mapper; } + + [HttpGet("group-messages")] + public async Task GetChatMessages( + [FromQuery] Guid chatId, + [FromQuery] Guid? startMessageId, + [FromQuery] int pageSize = 20) + { + try + { + var result = await _messagesLoader.LoadLastMessagesInChatGroup( + chatId, + _currentUser.GetCurrentUserId(), + startMessageId, + pageSize); + + var response = _mapper.Map>(result); + + return Ok(response); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex.Message); + return Unauthorized(ex.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + return StatusCode(500, "Unexpected Error! Please try again later."); + } + } + + [HttpGet("user-messages")] + public async Task GetUserMessages( + [FromQuery] Guid userId, + [FromQuery] Guid? startMessageId, + [FromQuery] int pageSize = 20) + { + try + { + var result = await _messagesLoader.LoadLastMessagesInUserChat( + userId, + _currentUser.GetCurrentUserId(), + startMessageId, + pageSize); + + var response = _mapper.Map>(result); + + return Ok(response); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, ex.Message); + return BadRequest(ex.Message); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex.Message); + return Unauthorized(ex.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + return StatusCode(500, "Unexpected Error! Please try again later."); + } + } + } \ No newline at end of file diff --git a/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs b/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs index 29fc7ee..02e86e3 100644 --- a/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs +++ b/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs @@ -1,3 +1,4 @@ +using AutoMapper; using Govor.Application.Interfaces.Friends; using Govor.Application.Interfaces.Infrastructure.Extensions; using Govor.Contracts.DTOs; @@ -15,12 +16,15 @@ public class FriendsRequestQueryController : Controller private readonly ILogger _logger; private readonly IFriendRequestQueryService _friendsService; private readonly ICurrentUserService _currentUserService; - + private readonly IMapper _mapper; + public FriendsRequestQueryController(ILogger logger, IFriendRequestQueryService friendsService, - ICurrentUserService currentUserService) + ICurrentUserService currentUserService, + IMapper mapper) { _logger = logger; + _mapper = mapper; _friendsService = friendsService; _currentUserService = currentUserService; } @@ -31,7 +35,9 @@ public class FriendsRequestQueryController : Controller try { var result = await _friendsService.GetIncomingAsync(_currentUserService.GetCurrentUserId()); - return Ok(BuildFriendshipDtos(result)); + var response = _mapper.Map>(result); + + return Ok(response); } catch (InvalidOperationException ex) { diff --git a/Govor.API/Controllers/Friends/FriendshipController.cs b/Govor.API/Controllers/Friends/FriendshipController.cs index e8bb89d..fa6cd10 100644 --- a/Govor.API/Controllers/Friends/FriendshipController.cs +++ b/Govor.API/Controllers/Friends/FriendshipController.cs @@ -1,3 +1,4 @@ +using AutoMapper; using Govor.Application.Exceptions.FriendsService; using Govor.Application.Interfaces.Friends; using Govor.Application.Interfaces.Infrastructure.Extensions; @@ -12,14 +13,16 @@ public class FriendshipController : Controller { private readonly ILogger _logger; private readonly IFriendshipService _friendsService; - //private readonly IUserDtoBuilder _builder; private readonly ICurrentUserService _currentUserService; - + private readonly IMapper _mapper; + public FriendshipController(ILogger logger, IFriendshipService friendsService, - ICurrentUserService currentUserService) + ICurrentUserService currentUserService, + IMapper mapper) { _logger = logger; + _mapper = mapper; _friendsService = friendsService; _currentUserService = currentUserService; } @@ -33,7 +36,10 @@ public class FriendshipController : Controller try { var result = await _friendsService.SearchUsersAsync(query, _currentUserService.GetCurrentUserId()); - return Ok(BuildUserDtos(result)); + + var response = _mapper.Map>(result); + + return Ok(response); } catch (SearchUsersException ex) { @@ -53,7 +59,10 @@ public class FriendshipController : Controller try { var result = await _friendsService.GetFriendsAsync(_currentUserService.GetCurrentUserId()); - return Ok(BuildUserDtos(result)); + + var response = _mapper.Map>(result); + + return Ok(response); } catch (InvalidOperationException ex) { @@ -66,14 +75,4 @@ public class FriendshipController : Controller return StatusCode(500, new { error = "Internal server error." }); } } - - private List BuildUserDtos(IEnumerable users) => users.Select(user => new UserDto - { - Id = user.Id, - Username = user.Username, - Description = user.Description, - WasOnline = user.WasOnline, - IconId = user.IconId - }).ToList(); - } \ No newline at end of file diff --git a/Govor.API/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Extensions/ConfigurationProgramExtensions.cs index b687326..c0b5812 100644 --- a/Govor.API/Extensions/ConfigurationProgramExtensions.cs +++ b/Govor.API/Extensions/ConfigurationProgramExtensions.cs @@ -65,6 +65,9 @@ public static class ConfigurationProgramExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + + // Auto Mapper + services.AddAutoMapper(typeof(MappingProfile)); } public static void AddRepositories(this IServiceCollection services) diff --git a/Govor.API/Extensions/MappingProfile.cs b/Govor.API/Extensions/MappingProfile.cs new file mode 100644 index 0000000..69045d6 --- /dev/null +++ b/Govor.API/Extensions/MappingProfile.cs @@ -0,0 +1,22 @@ +using AutoMapper; +using Govor.Contracts.DTOs; +using Govor.Contracts.Responses; +using Govor.Core.Models; +using Govor.Core.Models.Messages; +using Govor.Core.Models.Users; + +namespace Govor.API.Extensions; + +public class MappingProfile : Profile +{ + public MappingProfile() + { + CreateMap(); + CreateMap(); + CreateMap(); + CreateMap(); + + CreateMap(); + CreateMap(); + } +} \ No newline at end of file diff --git a/Govor.API/Govor.API.csproj b/Govor.API/Govor.API.csproj index b88211f..c7f5397 100644 --- a/Govor.API/Govor.API.csproj +++ b/Govor.API/Govor.API.csproj @@ -7,6 +7,8 @@ + + diff --git a/Govor.API/Hubs/ChatsHub.cs b/Govor.API/Hubs/ChatsHub.cs index cdd9d73..5db3c95 100644 --- a/Govor.API/Hubs/ChatsHub.cs +++ b/Govor.API/Hubs/ChatsHub.cs @@ -193,16 +193,25 @@ public class ChatsHub : Hub request.MessageId, request.NewEncryptedContent, DateTime.UtcNow); - + try { var result = await _messageCommandService.EditMessageAsync(editMessageParam); - - if(!result.IsSuccess) - return LogAndError(editor, request.MessageId, "Edit message error", result.Exception); - + + if (!result.IsSuccess) + return LogAndError(editor, request.MessageId, "Edit message error", + result.Exception); + return HubResult.Ok(); } + catch (UnauthorizedAccessException ex) + { + return LogAndUnauthorized(ex, "Unauthorized editing", editor, request.MessageId); + } + catch (KeyNotFoundException ex) + { + return LogAndNotFound(ex, "Message not found", editor, request.MessageId); + } catch (Exception ex) { return LogAndError(editor, request.MessageId, "Unhandled exception error", ex); diff --git a/Govor.API/Program.cs b/Govor.API/Program.cs index cacd149..1b4b4cb 100644 --- a/Govor.API/Program.cs +++ b/Govor.API/Program.cs @@ -96,6 +96,7 @@ builder.Services.AddSwaggerGen(options => }); }); + //builder.Services.AddOpenApi(); var app = builder.Build(); @@ -104,7 +105,7 @@ var app = builder.Build(); if (app.Environment.IsDevelopment()) { //app.MapOpenApi(); - + } app.UseSwagger(); diff --git a/Govor.Application/Services/Messages/MessagesLoader.cs b/Govor.Application/Services/Messages/MessagesLoader.cs index 3734002..541a400 100644 --- a/Govor.Application/Services/Messages/MessagesLoader.cs +++ b/Govor.Application/Services/Messages/MessagesLoader.cs @@ -1,24 +1,52 @@ using Govor.Application.Interfaces; +using Govor.Core.Infrastructure.Extensions; using Govor.Core.Models.Messages; using Govor.Core.Repositories.Groups; using Govor.Core.Repositories.Messages; +using Govor.Core.Repositories.PrivateChats; +using Govor.Data; using Govor.Data.Repositories.Exceptions; +using Microsoft.EntityFrameworkCore; namespace Govor.Application.Services.Messages; public class MessagesLoader : IMessagesLoader { - private IVerifyFriendship _friendship; private IGroupsRepository _groupsRepository; - private IMessagesRepository _messagesRepository; + private IPrivateChatsRepository _privateChatsRepository; + private GovorDbContext _dbContext; + + public MessagesLoader( + IGroupsRepository groupsRepository, + IPrivateChatsRepository privateChatsRepository, + GovorDbContext dbContext) + { + _groupsRepository = groupsRepository; + _privateChatsRepository = privateChatsRepository; + _dbContext = dbContext; + } public async Task> LoadLastMessagesInUserChat(Guid userId, Guid currentUser, Guid? startMessageId, int pageSize = 20) { - await _friendship.VerifyAsync(userId, currentUser); + if(userId == Guid.Empty) + throw new ArgumentException("User id cannot be empty"); + + if(!_privateChatsRepository.Exist(userId, currentUser)) + throw new InvalidOperationException("Private chat not found"); + try { - throw new NotImplementedException(); - //return await _groups.GetMessages(userId, startMessageId, pageSize); + var chat = await _privateChatsRepository.GetByMembersAsync(userId, currentUser); + + return await _dbContext.Messages + .AsNoTracking() + .Include(m => m.MediaAttachments) + .ThenInclude(m => m.MediaFile) + .AsSplitQuery() + .Where(m => m.RecipientType == RecipientType.User && + m.RecipientId == chat.Id) + .Take(pageSize) + .ToListOrThrowIfEmpty(new NotFoundException("Messages not found")); } catch (NotFoundException ex) { @@ -28,12 +56,23 @@ public class MessagesLoader : IMessagesLoader public async Task> LoadLastMessagesInChatGroup(Guid chatId, Guid currentUser, Guid? startMessageId, int pageSize = 20) { + if(chatId == Guid.Empty) + throw new ArgumentException("Chat id cannot be empty"); + if(!await _groupsRepository.IsUserMemberOfGroupAsync(currentUser, chatId)) throw new UnauthorizedAccessException("You are not in a group."); + try { - throw new NotImplementedException(); - //return await _groups.GetMessages(chatId, startMessageId, pageSize, RecipientType.Group); + return await _dbContext.Messages + .AsNoTracking() + .Include(m => m.MediaAttachments) + .ThenInclude(m => m.MediaFile) + .AsSplitQuery() + .Where(m => m.RecipientType == RecipientType.Group && + m.RecipientId == chatId) + .Take(pageSize) + .ToListOrThrowIfEmpty(new NotFoundException("Messages not found")); } catch (NotFoundException ex) { diff --git a/Govor.Console/Program.cs b/Govor.Console/Program.cs index bc21795..2247a05 100644 --- a/Govor.Console/Program.cs +++ b/Govor.Console/Program.cs @@ -28,8 +28,8 @@ namespace Govor.ConsoleClient { class Program { - //static string baseUrl = "https://govor-team-govor-88b3.twc1.net"; - static string baseUrl = "https://localhost:7155"; + static string baseUrl = "https://govor-team-govor-88b3.twc1.net"; + //static string baseUrl = "https://localhost:7155"; static string? AuthToken = null; static HttpClientService HttpService = new(baseUrl); private static FriendsClient? _friendsClient; // Renamed to avoid conflict with BaseCommand @@ -42,6 +42,12 @@ namespace Govor.ConsoleClient static async Task Main() { + FriendsHubClient client = new FriendsHubClient($"{baseUrl}/hubs/friends", "eyJhbGciOiJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGRzaWctbW9yZSNobWFjLXNoYTI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI4NDRjNzkyMi1hNjdlLTRlMzctYWI0MC0wNThlZDE5NzM5NjMiLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3JvbGUiOiJBZG1pbiIsImV4cCI6MTc1MjMzMjE2NX0.mGSjSCvguEXkdHqHcbZ3eUH0S8c82kz3XP89potEh1k"); + + await client.StartAsync(); + await client.AcceptRequest(Guid.Parse("3ba77c0c-7522-47be-8e77-ea47bd6c6e69")); + + return; InitializeCommands(); BaseCommand.InitializeServices( _friendsClient, // Initially null, will be set by Login/Register commands diff --git a/Govor.Contracts/Responses/MediaAttachmentResponse.cs b/Govor.Contracts/Responses/MediaAttachmentResponse.cs new file mode 100644 index 0000000..17a2600 --- /dev/null +++ b/Govor.Contracts/Responses/MediaAttachmentResponse.cs @@ -0,0 +1,8 @@ +namespace Govor.Contracts.Responses; + +public class MediaAttachmentResponse +{ + public Guid Id { get; set; } + public Guid MessageId { get; set; } + public Guid MediaFileId { get; set; } +} \ No newline at end of file diff --git a/Govor.Contracts/Responses/MessageReactionResponse.cs b/Govor.Contracts/Responses/MessageReactionResponse.cs new file mode 100644 index 0000000..d512e7d --- /dev/null +++ b/Govor.Contracts/Responses/MessageReactionResponse.cs @@ -0,0 +1,10 @@ +namespace Govor.Contracts.Responses; + +public class MessageReactionResponse +{ + public Guid Id { get; set; } + public Guid MessageId { get; set; } + public Guid UserId { get; set; } + public string ReactionCode { get; set; } // "❤️", "🔥", "👍", ":custom_emoji:" + public DateTime ReactedAt { get; set; } = DateTime.UtcNow; +} \ No newline at end of file diff --git a/Govor.Contracts/Responses/MessageResponse.cs b/Govor.Contracts/Responses/MessageResponse.cs new file mode 100644 index 0000000..60ea4ba --- /dev/null +++ b/Govor.Contracts/Responses/MessageResponse.cs @@ -0,0 +1,20 @@ +using Govor.Core.Models.Messages; + +namespace Govor.Contracts.Responses; + +public class MessageResponse +{ + public Guid Id { get; set; } + public Guid SenderId { get; set; } + public Guid RecipientId { get; set; } // or GroupId + public RecipientType RecipientType { get; set; } + public string EncryptedContent { get; set; } = string.Empty; + public DateTime SentAt { get; set; } + public bool IsEdited { get; set; } = false; + public DateTime? EditedAt { get; set; } + public Guid? ReplyToMessageId { get; set; } + + public List MediaAttachments { get; set; } = new(); + public List Reactions { get; set; } = new(); + public List MessageViews { get; set; } = new(); +} \ No newline at end of file diff --git a/Govor.Contracts/Responses/MessageViewResponse.cs b/Govor.Contracts/Responses/MessageViewResponse.cs new file mode 100644 index 0000000..f988a4e --- /dev/null +++ b/Govor.Contracts/Responses/MessageViewResponse.cs @@ -0,0 +1,9 @@ +namespace Govor.Contracts.Responses; + +public class MessageViewResponse +{ + public Guid Id { get; set; } + public Guid MessageId { get; set; } + public Guid UserId { get; set; } + public DateTime ViewedAt { get; set; } +} \ No newline at end of file diff --git a/Govor.Contracts/Responses/SignalR/HubResult.cs b/Govor.Contracts/Responses/SignalR/HubResult.cs index a75ca80..0875275 100644 --- a/Govor.Contracts/Responses/SignalR/HubResult.cs +++ b/Govor.Contracts/Responses/SignalR/HubResult.cs @@ -62,7 +62,7 @@ public class HubResult }; } -public enum HubResultStatus +public enum HubResultStatus : int { Success = 200, Created = 201, diff --git a/Govor.Core/Models/ChatGroup.cs b/Govor.Core/Models/ChatGroup.cs index beea1ee..406854e 100644 --- a/Govor.Core/Models/ChatGroup.cs +++ b/Govor.Core/Models/ChatGroup.cs @@ -1,3 +1,5 @@ +using Govor.Core.Models.Messages; + namespace Govor.Core.Models; public class ChatGroup @@ -11,7 +13,8 @@ public class ChatGroup public List Admins { get; set; } = new(); public List Members { get; set; } = new(); public List InviteCodes { get; set; } = new(); - + public List Messages { get; set; } = new(); + public override bool Equals(object? obj) { ChatGroup chatGroup = obj as ChatGroup; diff --git a/Govor.Core/Models/PrivateChat.cs b/Govor.Core/Models/PrivateChat.cs index df8cc29..e043a92 100644 --- a/Govor.Core/Models/PrivateChat.cs +++ b/Govor.Core/Models/PrivateChat.cs @@ -7,7 +7,7 @@ public class PrivateChat public Guid Id { get; set; } public Guid UserAId { get; set; } public Guid UserBId { get; set; } - public List Messages { get; set; } = new List(); + public List Messages { get; set; } = new(); public override bool Equals(object? obj) { diff --git a/Govor.Core/Repositories/PrivateChats/IPrivateChatsRepository.cs b/Govor.Core/Repositories/PrivateChats/IPrivateChatsRepository.cs index de632d6..7a9829c 100644 --- a/Govor.Core/Repositories/PrivateChats/IPrivateChatsRepository.cs +++ b/Govor.Core/Repositories/PrivateChats/IPrivateChatsRepository.cs @@ -1,3 +1,5 @@ +using Govor.Core.Repositories.Groups; + namespace Govor.Core.Repositories.PrivateChats; public interface IPrivateChatsRepository : IPrivateChatsReader, IPrivateChatsWriter, IPrivateChatsExist diff --git a/Govor.Data/Migrations/20250712090733_AddChatGroupIdToMessage.Designer.cs b/Govor.Data/Migrations/20250712090733_AddChatGroupIdToMessage.Designer.cs new file mode 100644 index 0000000..a320abd --- /dev/null +++ b/Govor.Data/Migrations/20250712090733_AddChatGroupIdToMessage.Designer.cs @@ -0,0 +1,593 @@ +// +using System; +using Govor.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Govor.Data.Migrations +{ + [DbContext(typeof(GovorDbContext))] + [Migration("20250712090733_AddChatGroupIdToMessage")] + partial class AddChatGroupIdToMessage + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("Govor.Core.Models.ChatGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ImageId") + .HasColumnType("char(36)"); + + b.Property("IsChannel") + .HasColumnType("tinyint(1)"); + + b.Property("IsPrivate") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("Id"); + + b.ToTable("ChatGroups"); + }); + + modelBuilder.Entity("Govor.Core.Models.Friendship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AddresseeId") + .HasColumnType("char(36)"); + + b.Property("RequesterId") + .HasColumnType("char(36)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AddresseeId"); + + b.HasIndex("RequesterId"); + + b.ToTable("Friendships"); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupAdmins", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupAdmins"); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("EndDate") + .HasColumnType("datetime(6)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("InvitationCode") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("MaxParticipants") + .HasColumnType("int"); + + b.Property("UserMakerId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("UserMakerId"); + + b.ToTable("GroupInvitations"); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupMembership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("InvitationId") + .IsRequired() + .HasColumnType("char(36)"); + + b.Property("IsBanned") + .HasColumnType("tinyint(1)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("InvitationId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("Govor.Core.Models.Invitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("DateCreated") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("EndDate") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("IsAdmin") + .HasColumnType("tinyint(1)"); + + b.Property("MaxParticipants") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Invitations"); + }); + + modelBuilder.Entity("Govor.Core.Models.MediaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("DateCreated") + .HasColumnType("datetime(6)"); + + b.Property("MediaType") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("MineType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("Url") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("MediaFiles"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.MediaAttachments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("MediaFileId") + .HasColumnType("char(36)"); + + b.Property("MessageId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("MediaFileId"); + + b.HasIndex("MessageId"); + + b.ToTable("MediaAttachments"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ChatGroupId") + .HasColumnType("char(36)"); + + b.Property("EditedAt") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedContent") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsEdited") + .HasColumnType("tinyint(1)"); + + b.Property("PrivateChatId") + .HasColumnType("char(36)"); + + b.Property("RecipientId") + .HasColumnType("char(36)"); + + b.Property("RecipientType") + .HasColumnType("int"); + + b.Property("ReplyToMessageId") + .HasColumnType("char(36)"); + + b.Property("SenderId") + .HasColumnType("char(36)"); + + b.Property("SentAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ChatGroupId"); + + b.HasIndex("PrivateChatId"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.MessageReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("MessageId") + .HasColumnType("char(36)"); + + b.Property("ReactedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReactionCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("MessageId", "UserId") + .IsUnique(); + + b.ToTable("MessageReactions"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.MessageView", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("MessageId") + .HasColumnType("char(36)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("ViewedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("MessageId", "UserId") + .IsUnique(); + + b.ToTable("MessageViews"); + }); + + modelBuilder.Entity("Govor.Core.Models.PrivateChat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("UserAId") + .HasColumnType("char(36)"); + + b.Property("UserBId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.ToTable("PrivateChats"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.Admin", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("UserId"); + + b.ToTable("Admins"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedOn") + .HasColumnType("date"); + + b.Property("Description") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IconId") + .HasColumnType("char(36)"); + + b.Property("InviteId") + .HasColumnType("char(36)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Username") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("WasOnline") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("InviteId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Govor.Core.Models.Friendship", b => + { + b.HasOne("Govor.Core.Models.Users.User", "Addressee") + .WithMany("ReceivedFriendRequests") + .HasForeignKey("AddresseeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Govor.Core.Models.Users.User", "Requester") + .WithMany("SentFriendRequests") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Addressee"); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupAdmins", b => + { + b.HasOne("Govor.Core.Models.ChatGroup", null) + .WithMany("Admins") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupInvitation", b => + { + b.HasOne("Govor.Core.Models.ChatGroup", null) + .WithMany("InviteCodes") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Govor.Core.Models.Users.User", "UserMaker") + .WithMany() + .HasForeignKey("UserMakerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("UserMaker"); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupMembership", b => + { + b.HasOne("Govor.Core.Models.ChatGroup", null) + .WithMany("Members") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Govor.Core.Models.GroupInvitation", null) + .WithMany() + .HasForeignKey("InvitationId") + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.MediaAttachments", b => + { + b.HasOne("Govor.Core.Models.MediaFile", "MediaFile") + .WithMany() + .HasForeignKey("MediaFileId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Govor.Core.Models.Messages.Message", "Message") + .WithMany("MediaAttachments") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MediaFile"); + + b.Navigation("Message"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.Message", b => + { + b.HasOne("Govor.Core.Models.ChatGroup", null) + .WithMany("Messages") + .HasForeignKey("ChatGroupId"); + + b.HasOne("Govor.Core.Models.PrivateChat", null) + .WithMany("Messages") + .HasForeignKey("PrivateChatId"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.MessageReaction", b => + { + b.HasOne("Govor.Core.Models.Messages.Message", "Message") + .WithMany("Reactions") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Govor.Core.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Message"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.MessageView", b => + { + b.HasOne("Govor.Core.Models.Messages.Message", null) + .WithMany("MessageViews") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.Admin", b => + { + b.HasOne("Govor.Core.Models.Users.User", "User") + .WithOne() + .HasForeignKey("Govor.Core.Models.Users.Admin", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.User", b => + { + b.HasOne("Govor.Core.Models.Invitation", "Invite") + .WithMany("Users") + .HasForeignKey("InviteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Invite"); + }); + + modelBuilder.Entity("Govor.Core.Models.ChatGroup", b => + { + b.Navigation("Admins"); + + b.Navigation("InviteCodes"); + + b.Navigation("Members"); + + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Govor.Core.Models.Invitation", b => + { + b.Navigation("Users"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.Message", b => + { + b.Navigation("MediaAttachments"); + + b.Navigation("MessageViews"); + + b.Navigation("Reactions"); + }); + + modelBuilder.Entity("Govor.Core.Models.PrivateChat", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.User", b => + { + b.Navigation("ReceivedFriendRequests"); + + b.Navigation("SentFriendRequests"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Govor.Data/Migrations/20250712090733_AddChatGroupIdToMessage.cs b/Govor.Data/Migrations/20250712090733_AddChatGroupIdToMessage.cs new file mode 100644 index 0000000..6eaab86 --- /dev/null +++ b/Govor.Data/Migrations/20250712090733_AddChatGroupIdToMessage.cs @@ -0,0 +1,50 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Govor.Data.Migrations +{ + /// + public partial class AddChatGroupIdToMessage : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ChatGroupId", + table: "Messages", + type: "char(36)", + nullable: true, + collation: "ascii_general_ci"); + + migrationBuilder.CreateIndex( + name: "IX_Messages_ChatGroupId", + table: "Messages", + column: "ChatGroupId"); + + migrationBuilder.AddForeignKey( + name: "FK_Messages_ChatGroups_ChatGroupId", + table: "Messages", + column: "ChatGroupId", + principalTable: "ChatGroups", + principalColumn: "Id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Messages_ChatGroups_ChatGroupId", + table: "Messages"); + + migrationBuilder.DropIndex( + name: "IX_Messages_ChatGroupId", + table: "Messages"); + + migrationBuilder.DropColumn( + name: "ChatGroupId", + table: "Messages"); + } + } +} diff --git a/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs b/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs index 13582b2..6937a5e 100644 --- a/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs +++ b/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs @@ -22,16 +22,6 @@ namespace Govor.Data.Migrations MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); - modelBuilder.Entity("Govor.Core.Models.Admin", b => - { - b.Property("UserId") - .HasColumnType("char(36)"); - - b.HasKey("UserId"); - - b.ToTable("Admins"); - }); - modelBuilder.Entity("Govor.Core.Models.ChatGroup", b => { b.Property("Id") @@ -207,27 +197,6 @@ namespace Govor.Data.Migrations b.ToTable("Invitations"); }); - modelBuilder.Entity("Govor.Core.Models.MediaAttachments", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("char(36)"); - - b.Property("MediaFileId") - .HasColumnType("char(36)"); - - b.Property("MessageId") - .HasColumnType("char(36)"); - - b.HasKey("Id"); - - b.HasIndex("MediaFileId"); - - b.HasIndex("MessageId"); - - b.ToTable("MediaAttachments"); - }); - modelBuilder.Entity("Govor.Core.Models.MediaFile", b => { b.Property("Id") @@ -255,12 +224,36 @@ namespace Govor.Data.Migrations b.ToTable("MediaFiles"); }); - modelBuilder.Entity("Govor.Core.Models.Message", b => + modelBuilder.Entity("Govor.Core.Models.Messages.MediaAttachments", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("char(36)"); + b.Property("MediaFileId") + .HasColumnType("char(36)"); + + b.Property("MessageId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("MediaFileId"); + + b.HasIndex("MessageId"); + + b.ToTable("MediaAttachments"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ChatGroupId") + .HasColumnType("char(36)"); + b.Property("EditedAt") .HasColumnType("datetime(6)"); @@ -291,12 +284,14 @@ namespace Govor.Data.Migrations b.HasKey("Id"); + b.HasIndex("ChatGroupId"); + b.HasIndex("PrivateChatId"); b.ToTable("Messages"); }); - modelBuilder.Entity("Govor.Core.Models.MessageReaction", b => + modelBuilder.Entity("Govor.Core.Models.Messages.MessageReaction", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -326,7 +321,7 @@ namespace Govor.Data.Migrations b.ToTable("MessageReactions"); }); - modelBuilder.Entity("Govor.Core.Models.MessageView", b => + modelBuilder.Entity("Govor.Core.Models.Messages.MessageView", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -366,7 +361,17 @@ namespace Govor.Data.Migrations b.ToTable("PrivateChats"); }); - modelBuilder.Entity("Govor.Core.Models.User", b => + modelBuilder.Entity("Govor.Core.Models.Users.Admin", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("UserId"); + + b.ToTable("Admins"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.User", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -403,26 +408,15 @@ namespace Govor.Data.Migrations b.ToTable("Users"); }); - modelBuilder.Entity("Govor.Core.Models.Admin", b => - { - b.HasOne("Govor.Core.Models.User", "User") - .WithOne() - .HasForeignKey("Govor.Core.Models.Admin", "UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - modelBuilder.Entity("Govor.Core.Models.Friendship", b => { - b.HasOne("Govor.Core.Models.User", "Addressee") + b.HasOne("Govor.Core.Models.Users.User", "Addressee") .WithMany("ReceivedFriendRequests") .HasForeignKey("AddresseeId") .OnDelete(DeleteBehavior.Restrict) .IsRequired(); - b.HasOne("Govor.Core.Models.User", "Requester") + b.HasOne("Govor.Core.Models.Users.User", "Requester") .WithMany("SentFriendRequests") .HasForeignKey("RequesterId") .OnDelete(DeleteBehavior.Restrict) @@ -450,7 +444,7 @@ namespace Govor.Data.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("Govor.Core.Models.User", "UserMaker") + b.HasOne("Govor.Core.Models.Users.User", "UserMaker") .WithMany() .HasForeignKey("UserMakerId") .OnDelete(DeleteBehavior.Restrict) @@ -474,7 +468,7 @@ namespace Govor.Data.Migrations .IsRequired(); }); - modelBuilder.Entity("Govor.Core.Models.MediaAttachments", b => + modelBuilder.Entity("Govor.Core.Models.Messages.MediaAttachments", b => { b.HasOne("Govor.Core.Models.MediaFile", "MediaFile") .WithMany() @@ -482,7 +476,7 @@ namespace Govor.Data.Migrations .OnDelete(DeleteBehavior.Restrict) .IsRequired(); - b.HasOne("Govor.Core.Models.Message", "Message") + b.HasOne("Govor.Core.Models.Messages.Message", "Message") .WithMany("MediaAttachments") .HasForeignKey("MessageId") .OnDelete(DeleteBehavior.Cascade) @@ -493,22 +487,26 @@ namespace Govor.Data.Migrations b.Navigation("Message"); }); - modelBuilder.Entity("Govor.Core.Models.Message", b => + modelBuilder.Entity("Govor.Core.Models.Messages.Message", b => { + b.HasOne("Govor.Core.Models.ChatGroup", null) + .WithMany("Messages") + .HasForeignKey("ChatGroupId"); + b.HasOne("Govor.Core.Models.PrivateChat", null) .WithMany("Messages") .HasForeignKey("PrivateChatId"); }); - modelBuilder.Entity("Govor.Core.Models.MessageReaction", b => + modelBuilder.Entity("Govor.Core.Models.Messages.MessageReaction", b => { - b.HasOne("Govor.Core.Models.Message", "Message") + b.HasOne("Govor.Core.Models.Messages.Message", "Message") .WithMany("Reactions") .HasForeignKey("MessageId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("Govor.Core.Models.User", "User") + b.HasOne("Govor.Core.Models.Users.User", "User") .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -519,16 +517,27 @@ namespace Govor.Data.Migrations b.Navigation("User"); }); - modelBuilder.Entity("Govor.Core.Models.MessageView", b => + modelBuilder.Entity("Govor.Core.Models.Messages.MessageView", b => { - b.HasOne("Govor.Core.Models.Message", null) + b.HasOne("Govor.Core.Models.Messages.Message", null) .WithMany("MessageViews") .HasForeignKey("MessageId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); - modelBuilder.Entity("Govor.Core.Models.User", b => + modelBuilder.Entity("Govor.Core.Models.Users.Admin", b => + { + b.HasOne("Govor.Core.Models.Users.User", "User") + .WithOne() + .HasForeignKey("Govor.Core.Models.Users.Admin", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.User", b => { b.HasOne("Govor.Core.Models.Invitation", "Invite") .WithMany("Users") @@ -546,6 +555,8 @@ namespace Govor.Data.Migrations b.Navigation("InviteCodes"); b.Navigation("Members"); + + b.Navigation("Messages"); }); modelBuilder.Entity("Govor.Core.Models.Invitation", b => @@ -553,7 +564,7 @@ namespace Govor.Data.Migrations b.Navigation("Users"); }); - modelBuilder.Entity("Govor.Core.Models.Message", b => + modelBuilder.Entity("Govor.Core.Models.Messages.Message", b => { b.Navigation("MediaAttachments"); @@ -567,7 +578,7 @@ namespace Govor.Data.Migrations b.Navigation("Messages"); }); - modelBuilder.Entity("Govor.Core.Models.User", b => + modelBuilder.Entity("Govor.Core.Models.Users.User", b => { b.Navigation("ReceivedFriendRequests"); diff --git a/Govor.Data/Repositories/PrivateChatsRepository.cs b/Govor.Data/Repositories/PrivateChatsRepository.cs index 07836ec..616362c 100644 --- a/Govor.Data/Repositories/PrivateChatsRepository.cs +++ b/Govor.Data/Repositories/PrivateChatsRepository.cs @@ -1,6 +1,7 @@ using Govor.Core.Infrastructure.Extensions; using Govor.Core.Infrastructure.Validators; using Govor.Core.Models; +using Govor.Core.Models.Messages; using Govor.Core.Repositories.PrivateChats; using Govor.Data.Repositories.Exceptions; using Microsoft.EntityFrameworkCore; From 97b2ea14b286f41d7d95fee647df0e4d5bb53bdc Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Sat, 12 Jul 2025 19:51:15 +0700 Subject: [PATCH 046/101] Test for friends controllers --- .../Controllers/FriendsControllerTests.cs | 386 ------------------ .../FriendsRequestQueryControllerTests.cs | 196 +++++++++ .../Controllers/FriendshipControllerTests.cs | 191 +++++++++ .../Friends/FriendsRequestQueryController.cs | 16 +- 4 files changed, 392 insertions(+), 397 deletions(-) delete mode 100644 Govor.API.Tests/IntegrationTests/Controllers/FriendsControllerTests.cs create mode 100644 Govor.API.Tests/IntegrationTests/Controllers/FriendsRequestQueryControllerTests.cs create mode 100644 Govor.API.Tests/IntegrationTests/Controllers/FriendshipControllerTests.cs diff --git a/Govor.API.Tests/IntegrationTests/Controllers/FriendsControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/FriendsControllerTests.cs deleted file mode 100644 index cb3d70d..0000000 --- a/Govor.API.Tests/IntegrationTests/Controllers/FriendsControllerTests.cs +++ /dev/null @@ -1,386 +0,0 @@ -using AutoFixture; -using Govor.API.Controllers; -using Govor.Application.Exceptions.FriendsService; -using Govor.Application.Interfaces; -using Govor.Application.Interfaces.Infrastructure.Extensions; -using Govor.Contracts.DTOs; -using Govor.Core.Models; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using Moq; - -namespace Govor.API.Tests.IntegrationTests.Controllers; - -[TestFixture] -public class FriendsControllerTests -{ - /* - private Fixture _fixture; - private Mock> _loggerMock; - private Mock _friendsServiceMock; - private Mock _currentUserServiceMock; - private FriendsController _controller; - - [SetUp] - public void SetUp() - { - _fixture = new Fixture(); - _fixture.Behaviors.OfType().ToList().ForEach(b => _fixture.Behaviors.Remove(b)); - _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); - - _loggerMock = new Mock>(); - _friendsServiceMock = new Mock(); - _currentUserServiceMock = new Mock(); - - _controller = new FriendsController( - _loggerMock.Object, - _friendsServiceMock.Object, - _currentUserServiceMock.Object - ); - } - // Tests for Search action - [Test] - public async Task Search_ValidRequest_ReturnsOkResult() - { - var users = _fixture.CreateMany().ToList(); - var userId = _fixture.Create(); - var query = _fixture.Create(); - - _currentUserServiceMock.Setup(c => c.GetCurrentUserId()).Returns(userId); - - _friendsServiceMock.Setup(f => f.SearchUsersAsync(query, userId)) - .ReturnsAsync(users); - - // Act - var result = await _controller.Search(query); - - var okResult = result as OkObjectResult; - dynamic value = okResult.Value; - - List userDtos = value as List; - - // Assert - Assert.That(value, Is.Not.Null); - Assert.That(value.Count, Is.EqualTo(users.Count)); - Assert.That(userDtos.Select(u => u.Id), Is.EqualTo(users.Select(u => u.Id))); - } - - [Test] - public async Task Search_InvalidQuery_BadRequest() - { - // Act - var result = await _controller.Search(string.Empty); - // Assert - Assert.That(result, Is.InstanceOf()); - var badRequestResult = result as BadRequestObjectResult; - Assert.That(badRequestResult.Value, Is.EqualTo("Query cannot be empty")); - } - - - [Test] - public async Task Search_NotFound_IfThrowsSearchUsersException() - { - // Arrange - _friendsServiceMock.Setup(f => f.SearchUsersAsync(It.IsAny(), It.IsAny())) - .ThrowsAsync(new SearchUsersException(_fixture.Create())); - - // Act - var result = await _controller.Search(_fixture.Create()); - // Assert - Assert.That(result, Is.InstanceOf()); - } - - [Test] - public async Task Search_StatusCode500_IfThrowsSomeException() - { - // Arrange - _friendsServiceMock.Setup(f => f.SearchUsersAsync(It.IsAny(), It.IsAny())) - .ThrowsAsync(new Exception(_fixture.Create())); - - // Act - var result = await _controller.Search(_fixture.Create()); - // Assert - Assert.That(result, Is.InstanceOf()); - var objectResult = result as ObjectResult; - Assert.That(objectResult.StatusCode, Is.EqualTo(500)); - } - - // Test for SendRequest action - [Test] - public async Task SendRequest_ValidRequest_ReturnsOk() - { - var targetUserId = Guid.NewGuid(); - - var result = await _controller.SendRequest(targetUserId); - - Assert.That(result, Is.InstanceOf()); - var okResult = result as OkObjectResult; - Assert.That(okResult?.Value.ToString(), Does.Contain("Friend request sent")); - } - - [Test] - public async Task SendRequest_Throws_InvalidOperationException_ReturnsUnprocessableEntity() - { - var targetUserId = Guid.NewGuid(); - - _currentUserServiceMock.Setup(c => c.GetCurrentUserId()).Returns(targetUserId); - - _friendsServiceMock.Setup(f => f.SendFriendRequestAsync(targetUserId, targetUserId)) - .ThrowsAsync(new InvalidOperationException()); - - var result = await _controller.SendRequest(targetUserId); - - Assert.That(result, Is.InstanceOf()); - } - - [Test] - public async Task SendRequest_Throws_RequestAlreadySentException_ReturnsConflict() - { - var targetUserId = Guid.NewGuid(); - var currentUserId = Guid.NewGuid(); - - _currentUserServiceMock.Setup(c => c.GetCurrentUserId()).Returns(currentUserId); - - _friendsServiceMock.Setup(f => f.SendFriendRequestAsync(currentUserId, targetUserId)) - .ThrowsAsync(new RequestAlreadySentException(currentUserId, targetUserId)); - - var result = await _controller.SendRequest(targetUserId); - - Assert.That(result, Is.InstanceOf()); - } - - [Test] - public async Task SendRequest_StatusCode500_IfThrowsSomeException() - { - // Arrange - _friendsServiceMock.Setup(f => f.SendFriendRequestAsync(It.IsAny(), It.IsAny())) - .ThrowsAsync(new Exception(_fixture.Create())); - - // Act - var result = await _controller.Search(_fixture.Create()); - - // Assert - Assert.That(result, Is.InstanceOf()); - var objectResult = result as ObjectResult; - Assert.That(objectResult.StatusCode, Is.EqualTo(500)); - } - - // Tests for GetIncomingRequests action - [Test] - public async Task GetIncomingRequests_ValidRequest_ReturnsOkResult() - { - // Arrange - var currentId = _fixture.Create(); - var friendships = _fixture.CreateMany().ToList(); - - _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) - .Returns(currentId); - - _friendsServiceMock.Setup(f => f.GetIncomingRequestsAsync(currentId)) - .ReturnsAsync(friendships); - // Act - var result = await _controller.GetIncomingRequests(); - - // Assert - Assert.That(result, Is.InstanceOf()); - var okResult = result as OkObjectResult; - List value = okResult.Value as List; - Assert.That(value, Is.Not.Null); - Assert.That(value.Count, Is.EqualTo(friendships.Count)); - } - - [Test] - public async Task GetIncomingRequests_Throws_InvalidOperationException_ReturnsBadRequest() - { - // Arrange - var currentId = _fixture.Create(); - - _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) - .Returns(currentId); - - _friendsServiceMock.Setup(f => f.GetIncomingRequestsAsync(currentId)) - .ThrowsAsync(new InvalidOperationException()); - - // Act - var result = await _controller.GetIncomingRequests(); - - // Assert - Assert.That(result, Is.InstanceOf()); - var badRequestResult = result as BadRequestObjectResult; - dynamic value = badRequestResult.Value; - Assert.That(value, Is.EqualTo("Failed to get friend requests. User data missing.")); - } - - [Test] - public async Task GetIncomingRequest_StatusCode500_IfThrowsSomeException() - { - // Arrange - var currentId = _fixture.Create(); - - _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) - .Returns(currentId); - - _friendsServiceMock.Setup(f => f.GetIncomingRequestsAsync(currentId)) - .ThrowsAsync(new Exception()); - - // Act - var result = await _controller.GetIncomingRequests(); - - // Assert - Assert.That(result, Is.InstanceOf()); - var objectResult = result as ObjectResult; - Assert.That(objectResult.StatusCode, Is.EqualTo(500)); - } - - // Tests for AcceptFriend action - [Test] - public async Task AcceptFriend_ValidRequest_ReturnsOkResult() - { - // Arrange - var currentId = Guid.NewGuid(); - var targetUserId = Guid.NewGuid(); - - _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) - .Returns(currentId); - // Act - var result = await _controller.AcceptFriend(targetUserId); - - // Assert - Assert.That(result, Is.InstanceOf()); - } - - [Test] - public async Task AcceptFriend_InvalidOperationException_ReturnsNotFound() - { - // Arrange - var currentId = Guid.NewGuid(); - var targetUserId = Guid.NewGuid(); - - _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) - .Returns(currentId); - - _friendsServiceMock.Setup(f => f.AcceptFriendRequestAsync(targetUserId, currentId)) - .ThrowsAsync(new InvalidOperationException()); - - // Act - var result = await _controller.AcceptFriend(targetUserId); - - // Assert - Assert.That(result, Is.InstanceOf()); - } - [Test] - public async Task AcceptFriend_UnauthorizedAccessException_ReturnsForbid() - { - // Arrange - var currentId = Guid.NewGuid(); - var targetUserId = Guid.NewGuid(); - - _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) - .Returns(currentId); - - _friendsServiceMock.Setup(f => f.AcceptFriendRequestAsync(targetUserId, currentId)) - .ThrowsAsync(new UnauthorizedAccessException()); - - // Act - var result = await _controller.AcceptFriend(targetUserId); - - // Assert - Assert.That(result, Is.InstanceOf()); - } - - [Test] - public async Task AcceptFriend_Exception_ReturnsStatusCode500() - { - // Arrange - var currentId = Guid.NewGuid(); - var targetUserId = Guid.NewGuid(); - - _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) - .Returns(currentId); - - _friendsServiceMock.Setup(f => f.AcceptFriendRequestAsync(targetUserId, currentId)) - .ThrowsAsync(new Exception()); - - // Act - var result = await _controller.AcceptFriend(targetUserId); - - // Assert - Assert.That(result, Is.InstanceOf()); - var objectResult = result as ObjectResult; - Assert.That(objectResult.StatusCode, Is.EqualTo(500)); - } - - // Tests for GetFriends action - [Test] - public async Task GetFriends_ValidRequest_ReturnsOkResult() - { - // Arrange - var currentId = _fixture.Create(); - var users = _fixture.CreateMany().ToList(); - - _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) - .Returns(currentId); - - _friendsServiceMock.Setup(f => f.GetFriendsAsync(currentId)) - .ReturnsAsync(users); - // Act - var result = await _controller.GetFriends(); - - // Assert - Assert.That(result, Is.InstanceOf()); - var okResult = result as OkObjectResult; - List value = okResult.Value as List; - Assert.That(value, Is.Not.Null); - Assert.That(value.Count, Is.EqualTo(users.Count)); - } - - [Test] - public async Task GetFriends_InvalidOperationException_ReturnsBadRequest() - { - // Arrange - var currentId = _fixture.Create(); - var users = _fixture.CreateMany().ToList(); - - _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) - .Returns(currentId); - - _friendsServiceMock.Setup(f => f.GetFriendsAsync(currentId)) - .ThrowsAsync(new InvalidOperationException()); - - // Act - var result = await _controller.GetFriends(); - - // Assert - Assert.That(result, Is.InstanceOf()); - } - - [Test] - public async Task GetFriends_Exception_ReturnsStatusCode500() - { - // Arrange - var currentId = _fixture.Create(); - var users = _fixture.CreateMany().ToList(); - - _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) - .Returns(currentId); - - _friendsServiceMock.Setup(f => f.GetFriendsAsync(currentId)) - .ThrowsAsync(new Exception()); - - // Act - var result = await _controller.GetFriends(); - - // Assert - Assert.That(result, Is.InstanceOf()); - var objectResult = result as ObjectResult; - Assert.That(objectResult.StatusCode, Is.EqualTo(500)); - } - - - [TearDown] - public void TearDown() - { - _controller.Dispose(); - } - */ -} \ No newline at end of file diff --git a/Govor.API.Tests/IntegrationTests/Controllers/FriendsRequestQueryControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/FriendsRequestQueryControllerTests.cs new file mode 100644 index 0000000..8b6c204 --- /dev/null +++ b/Govor.API.Tests/IntegrationTests/Controllers/FriendsRequestQueryControllerTests.cs @@ -0,0 +1,196 @@ +using AutoFixture; +using AutoMapper; +using Govor.API.Controllers.Friends; +using Govor.Application.Interfaces.Friends; +using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.Contracts.DTOs; +using Govor.Core.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Govor.API.Tests.IntegrationTests.Controllers; + +[TestFixture] +public class FriendsRequestQueryControllerTests +{ + private Fixture _fixture; + private Mock> _loggerMock; + private Mock _friendsServiceMock; + private Mock _currentUserServiceMock; + private Mock _mapperMock; + private FriendsRequestQueryController _controller; + + [SetUp] + public void SetUp() + { + _fixture = new Fixture(); + _fixture.Behaviors.OfType().ToList().ForEach(b => _fixture.Behaviors.Remove(b)); + _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); + + _loggerMock = new Mock>(); + _friendsServiceMock = new Mock(); + _currentUserServiceMock = new Mock(); + _mapperMock = new Mock(); + + _controller = new FriendsRequestQueryController( + _loggerMock.Object, + _friendsServiceMock.Object, + _currentUserServiceMock.Object, + _mapperMock.Object + ); + } + + [Test] + public async Task GetIncomingRequests_ValidRequest_ReturnsOkResult() + { + // Arrange + var currentId = _fixture.Create(); + var friendships = _fixture.CreateMany().ToList(); + var dtos = friendships.Select(f => new FriendshipDto() + { AddresseeId = f.AddresseeId, RequesterId = f.RequesterId }).ToList(); + + _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) + .Returns(currentId); + + _friendsServiceMock.Setup(f => f.GetIncomingAsync(currentId)) + .ReturnsAsync(friendships); + + _mapperMock.Setup(f => f.Map>(friendships)).Returns(dtos); + + // Act + var result = await _controller.GetIncomingRequests(); + + // Assert + Assert.That(result, Is.InstanceOf()); + var okResult = result as OkObjectResult; + List value = okResult.Value as List; + + Assert.That(value, Is.Not.Null); + Assert.That(value.Count, Is.EqualTo(friendships.Count)); + Assert.That(value.Select(f => f.AddresseeId), Is.EquivalentTo(friendships.Select(f => f.AddresseeId))); + Assert.That(value.Select(f => f.RequesterId), Is.EquivalentTo(friendships.Select(f => f.RequesterId))); + } + + [Test] + public async Task GetIncomingRequests_Throws_InvalidOperationException_ReturnsBadRequest() + { + // Arrange + var currentId = _fixture.Create(); + + _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) + .Returns(currentId); + + _friendsServiceMock.Setup(f => f.GetIncomingAsync(currentId)) + .ThrowsAsync(new InvalidOperationException()); + + // Act + var result = await _controller.GetIncomingRequests(); + + // Assert + Assert.That(result, Is.InstanceOf()); + var okResult = result as OkObjectResult; + List value = okResult.Value as List; + + Assert.That(value, Is.Not.Null); + Assert.That(value.Count, Is.EqualTo(0)); + } + + [Test] + public async Task GetIncomingRequest_StatusCode500_IfThrowsSomeException() + { + // Arrange + var currentId = _fixture.Create(); + + _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) + .Returns(currentId); + + _friendsServiceMock.Setup(f => f.GetIncomingAsync(currentId)) + .ThrowsAsync(new Exception()); + + // Act + var result = await _controller.GetIncomingRequests(); + + // Assert + Assert.That(result, Is.InstanceOf()); + var objectResult = result as ObjectResult; + Assert.That(objectResult.StatusCode, Is.EqualTo(500)); + } + + // Test for GetResponses action + [Test] + public async Task GetResponses_ValidRequest_ReturnsOkResult() + { + // Arrange + var currentId = _fixture.Create(); + var friendships = _fixture.CreateMany().ToList(); + var dtos = friendships.Select(f => new FriendshipDto() + { AddresseeId = f.AddresseeId, RequesterId = f.RequesterId }).ToList(); + + _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) + .Returns(currentId); + + _friendsServiceMock.Setup(f => f.GetResponsesAsync(currentId)) + .ReturnsAsync(friendships); + + _mapperMock.Setup(f => f.Map>(friendships)).Returns(dtos); + + // Act + var result = await _controller.GetResponses(); + + // Assert + Assert.That(result, Is.InstanceOf()); + var okResult = result as OkObjectResult; + List value = okResult.Value as List; + + Assert.That(value, Is.Not.Null); + Assert.That(value.Count, Is.EqualTo(friendships.Count)); + Assert.That(value.Select(f => f.AddresseeId), Is.EquivalentTo(friendships.Select(f => f.AddresseeId))); + Assert.That(value.Select(f => f.RequesterId), Is.EquivalentTo(friendships.Select(f => f.RequesterId))); + } + + [Test] + public async Task GetResponses_Throws_InvalidOperationException_ReturnsEmptyList() + { + // Arrange + var currentId = _fixture.Create(); + + _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) + .Returns(currentId); + + _friendsServiceMock.Setup(f => f.GetResponsesAsync(currentId)) + .ThrowsAsync(new InvalidOperationException()); + + // Act + var result = await _controller.GetResponses(); + + // Assert + Assert.That(result, Is.InstanceOf()); + var okResult = result as OkObjectResult; + List value = okResult.Value as List; + + Assert.That(value, Is.Not.Null); + Assert.That(value.Count, Is.EqualTo(0)); + } + + [Test] + public async Task GetResponses_StatusCode500_IfThrowsSomeException() + { + // Arrange + var currentId = _fixture.Create(); + + _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) + .Returns(currentId); + + _friendsServiceMock.Setup(f => f.GetResponsesAsync(currentId)) + .ThrowsAsync(new Exception()); + + // Act + var result = await _controller.GetResponses(); + + // Assert + Assert.That(result, Is.InstanceOf()); + var objectResult = result as ObjectResult; + Assert.That(objectResult.StatusCode, Is.EqualTo(500)); + } +} \ No newline at end of file diff --git a/Govor.API.Tests/IntegrationTests/Controllers/FriendshipControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/FriendshipControllerTests.cs new file mode 100644 index 0000000..85bda4b --- /dev/null +++ b/Govor.API.Tests/IntegrationTests/Controllers/FriendshipControllerTests.cs @@ -0,0 +1,191 @@ +using AutoFixture; +using AutoMapper; +using Govor.API.Controllers.Friends; +using Govor.Application.Exceptions.FriendsService; +using Govor.Application.Interfaces.Friends; +using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.Contracts.DTOs; +using Govor.Core.Models.Users; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Govor.API.Tests.IntegrationTests.Controllers; + +[TestFixture] +public class FriendshipControllerTests +{ + private Fixture _fixture; + private Mock> _loggerMock; + private Mock _friendsServiceMock; + private Mock _currentUserServiceMock; + private Mock _mapperMock; + private FriendshipController _controller; + + [SetUp] + public void SetUp() + { + _fixture = new Fixture(); + _fixture.Behaviors.OfType().ToList().ForEach(b => _fixture.Behaviors.Remove(b)); + _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); + + _loggerMock = new Mock>(); + _friendsServiceMock = new Mock(); + _currentUserServiceMock = new Mock(); + _mapperMock = new Mock(); + + _controller = new FriendshipController( + _loggerMock.Object, + _friendsServiceMock.Object, + _currentUserServiceMock.Object, + _mapperMock.Object + ); + } + + // Tests for Search action + [Test] + public async Task Search_ValidRequest_ReturnsOkResult() + { + var users = _fixture.CreateMany().ToList(); + var dtos = users.Select(u => new UserDto { Id = u.Id }).ToList(); + var userId = _fixture.Create(); + var query = _fixture.Create(); + + _currentUserServiceMock.Setup(c => c.GetCurrentUserId()).Returns(userId); + + _friendsServiceMock.Setup(f => f.SearchUsersAsync(query, userId)) + .ReturnsAsync(users); + + _mapperMock.Setup(m => m.Map>(users)).Returns(dtos); + + // Act + var result = await _controller.Search(query); + + var okResult = result as OkObjectResult; + dynamic value = okResult.Value; + + List userDtos = value as List; + + // Assert + Assert.That(value, Is.Not.Null); + Assert.That(value.Count, Is.EqualTo(users.Count)); + Assert.That(userDtos.Select(u => u.Id), Is.EqualTo(users.Select(u => u.Id))); + } + + [Test] + public async Task Search_InvalidQuery_BadRequest() + { + // Act + var result = await _controller.Search(string.Empty); + // Assert + Assert.That(result, Is.InstanceOf()); + var badRequestResult = result as BadRequestObjectResult; + Assert.That(badRequestResult.Value, Is.EqualTo("Query cannot be empty")); + } + + + [Test] + public async Task Search_NotFound_IfThrowsSearchUsersException() + { + // Arrange + _friendsServiceMock.Setup(f => f.SearchUsersAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new SearchUsersException(_fixture.Create())); + + // Act + var result = await _controller.Search(_fixture.Create()); + // Assert + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task Search_StatusCode500_IfThrowsSomeException() + { + // Arrange + _friendsServiceMock.Setup(f => f.SearchUsersAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception(_fixture.Create())); + + // Act + var result = await _controller.Search(_fixture.Create()); + // Assert + Assert.That(result, Is.InstanceOf()); + var objectResult = result as ObjectResult; + Assert.That(objectResult.StatusCode, Is.EqualTo(500)); + } + + [Test] + public async Task GetFriends_ValidRequest_ReturnsOkResult() + { + // Arrange + var currentId = _fixture.Create(); + var users = _fixture.CreateMany().ToList(); + var dtos = users.Select(u => new UserDto { Id = u.Id }).ToList(); + + _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) + .Returns(currentId); + + _friendsServiceMock.Setup(f => f.GetFriendsAsync(currentId)) + .ReturnsAsync(users); + + _mapperMock.Setup(m => m.Map>(users)).Returns(dtos); + + // Act + var result = await _controller.GetFriends(); + + // Assert + Assert.That(result, Is.InstanceOf()); + var okResult = result as OkObjectResult; + List value = okResult.Value as List; + Assert.That(value, Is.Not.Null); + Assert.That(value.Count, Is.EqualTo(users.Count)); + Assert.That(value.Select(u => u.Id), Is.EqualTo(users.Select(u => u.Id))); + } + + [Test] + public async Task GetFriends_InvalidOperationException_ReturnsBadRequest() + { + // Arrange + var currentId = _fixture.Create(); + var users = _fixture.CreateMany().ToList(); + + _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) + .Returns(currentId); + + _friendsServiceMock.Setup(f => f.GetFriendsAsync(currentId)) + .ThrowsAsync(new InvalidOperationException()); + + // Act + var result = await _controller.GetFriends(); + + // Assert + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task GetFriends_Exception_ReturnsStatusCode500() + { + // Arrange + var currentId = _fixture.Create(); + var users = _fixture.CreateMany().ToList(); + + _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) + .Returns(currentId); + + _friendsServiceMock.Setup(f => f.GetFriendsAsync(currentId)) + .ThrowsAsync(new Exception()); + + // Act + var result = await _controller.GetFriends(); + + // Assert + Assert.That(result, Is.InstanceOf()); + var objectResult = result as ObjectResult; + Assert.That(objectResult.StatusCode, Is.EqualTo(500)); + } + + + [TearDown] + public void TearDown() + { + _controller.Dispose(); + } +} \ No newline at end of file diff --git a/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs b/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs index 02e86e3..77deda3 100644 --- a/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs +++ b/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs @@ -42,7 +42,7 @@ public class FriendsRequestQueryController : Controller catch (InvalidOperationException ex) { _logger.LogError(ex, ex.Message); - return BadRequest("Failed to get friend requests. User data missing."); + return Ok(new List()); } catch (Exception ex) { @@ -57,12 +57,14 @@ public class FriendsRequestQueryController : Controller try { var result = await _friendsService.GetResponsesAsync(_currentUserService.GetCurrentUserId()); - return Ok(BuildFriendshipDtos(result)); + var response = _mapper.Map>(result); + + return Ok(response); } catch (InvalidOperationException ex) { _logger.LogError(ex, ex.Message); - return Ok(Array.Empty()); + return Ok(new List()); } catch (Exception ex) { @@ -70,12 +72,4 @@ public class FriendsRequestQueryController : Controller return StatusCode(500, new { error = "Internal server error." }); } } - - private List BuildFriendshipDtos(IEnumerable friendships) => friendships.Select(f => new FriendshipDto - { - Id = f.Id, - Status = f.Status, - AddresseeId = f.AddresseeId, - RequesterId = f.RequesterId, - }).ToList(); } \ No newline at end of file From 6063aafd9e28976f8b08c972f622faaec0c92f1f Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Sun, 13 Jul 2025 19:03:01 +0700 Subject: [PATCH 047/101] Refactor friend request and media services, remove console commands Refactored the friend request command service and SignalR hub to return and broadcast FriendshipDto objects, improving real-time updates. Enhanced media upload and download logic to support file storage and retrieval, including database integration. Removed all command classes and related infrastructure from the Govor.Console project, streamlining the console client. Updated dependency injection and interfaces to reflect these changes. --- .../FriendsRequestQueryControllerTests.cs | 10 +- .../IntegrationTests/Hubs/FriendsHubTests.cs | 91 ++- .../AdminStuff/FriendshipsController.cs | 21 +- .../Friends/FriendsRequestQueryController.cs | 6 +- Govor.API/Controllers/MediaController.cs | 59 +- .../ConfigurationProgramExtensions.cs | 2 + Govor.API/Hubs/ChatsHub.cs | 32 +- Govor.API/Hubs/FriendsHub.cs | 29 +- .../Friends/IFriendRequestCommandService.cs | 8 +- .../Interfaces/Medias/IMediaService.cs | 11 +- .../Friends/FriendRequestCommandService.cs | 17 +- .../Services/Messages/MediaService.cs | 79 ++- .../Commands/AcceptFriendRequestCommand.cs | 42 -- .../AdminListAllFriendshipsCommand.cs | 58 -- .../AdminListUserFriendshipsCommand.cs | 65 -- .../Commands/AdminRemoveFriendshipCommand.cs | 57 -- Govor.Console/Commands/BaseCommand.cs | 65 -- Govor.Console/Commands/BlockUserCommand.cs | 43 -- Govor.Console/Commands/HelpCommand.cs | 49 -- Govor.Console/Commands/ICommand.cs | 8 - Govor.Console/Commands/ListFriendsCommand.cs | 40 -- .../Commands/ListIncomingRequestsCommand.cs | 44 -- .../Commands/ListOutgoingRequestsCommand.cs | 53 -- Govor.Console/Commands/LoginCommand.cs | 51 -- Govor.Console/Commands/RegisterCommand.cs | 53 -- .../Commands/RejectFriendRequestCommand.cs | 43 -- Govor.Console/Commands/SearchUserCommand.cs | 51 -- .../Commands/SendFriendRequestCommand.cs | 42 -- Govor.Console/Commands/UnblockUserCommand.cs | 43 -- Govor.Console/FriendsHubClient.cs | 171 ++--- Govor.Console/Govor.Console.csproj | 1 + Govor.Console/Program.cs | 413 ++---------- Govor.Contracts/Govor.Contracts.csproj | 6 + .../Requests/MediaUploadRequest.cs | 5 +- .../Responses/SignalR/MessageEditResponse.cs | 9 +- Govor.Core/Models/MediaFile.cs | 1 + .../Models/Users/PrivacyUserSettings.cs | 2 +- ...01943_AddUploaderIdToMediaFile.Designer.cs | 596 ++++++++++++++++++ ...20250713101943_AddUploaderIdToMediaFile.cs | 31 + .../Migrations/GovorDbContextModelSnapshot.cs | 3 + 40 files changed, 1029 insertions(+), 1381 deletions(-) delete mode 100644 Govor.Console/Commands/AcceptFriendRequestCommand.cs delete mode 100644 Govor.Console/Commands/AdminListAllFriendshipsCommand.cs delete mode 100644 Govor.Console/Commands/AdminListUserFriendshipsCommand.cs delete mode 100644 Govor.Console/Commands/AdminRemoveFriendshipCommand.cs delete mode 100644 Govor.Console/Commands/BaseCommand.cs delete mode 100644 Govor.Console/Commands/BlockUserCommand.cs delete mode 100644 Govor.Console/Commands/HelpCommand.cs delete mode 100644 Govor.Console/Commands/ICommand.cs delete mode 100644 Govor.Console/Commands/ListFriendsCommand.cs delete mode 100644 Govor.Console/Commands/ListIncomingRequestsCommand.cs delete mode 100644 Govor.Console/Commands/ListOutgoingRequestsCommand.cs delete mode 100644 Govor.Console/Commands/LoginCommand.cs delete mode 100644 Govor.Console/Commands/RegisterCommand.cs delete mode 100644 Govor.Console/Commands/RejectFriendRequestCommand.cs delete mode 100644 Govor.Console/Commands/SearchUserCommand.cs delete mode 100644 Govor.Console/Commands/SendFriendRequestCommand.cs delete mode 100644 Govor.Console/Commands/UnblockUserCommand.cs create mode 100644 Govor.Data/Migrations/20250713101943_AddUploaderIdToMediaFile.Designer.cs create mode 100644 Govor.Data/Migrations/20250713101943_AddUploaderIdToMediaFile.cs diff --git a/Govor.API.Tests/IntegrationTests/Controllers/FriendsRequestQueryControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/FriendsRequestQueryControllerTests.cs index 8b6c204..41b0c58 100644 --- a/Govor.API.Tests/IntegrationTests/Controllers/FriendsRequestQueryControllerTests.cs +++ b/Govor.API.Tests/IntegrationTests/Controllers/FriendsRequestQueryControllerTests.cs @@ -48,7 +48,10 @@ public class FriendsRequestQueryControllerTests var currentId = _fixture.Create(); var friendships = _fixture.CreateMany().ToList(); var dtos = friendships.Select(f => new FriendshipDto() - { AddresseeId = f.AddresseeId, RequesterId = f.RequesterId }).ToList(); + { + AddresseeId = f.AddresseeId, + RequesterId = f.RequesterId + }).ToList(); _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) .Returns(currentId); @@ -125,7 +128,10 @@ public class FriendsRequestQueryControllerTests var currentId = _fixture.Create(); var friendships = _fixture.CreateMany().ToList(); var dtos = friendships.Select(f => new FriendshipDto() - { AddresseeId = f.AddresseeId, RequesterId = f.RequesterId }).ToList(); + { + AddresseeId = f.AddresseeId, + RequesterId = f.RequesterId + }).ToList(); _currentUserServiceMock.Setup(c => c.GetCurrentUserId()) .Returns(currentId); diff --git a/Govor.API.Tests/IntegrationTests/Hubs/FriendsHubTests.cs b/Govor.API.Tests/IntegrationTests/Hubs/FriendsHubTests.cs index 9f88695..57010f7 100644 --- a/Govor.API.Tests/IntegrationTests/Hubs/FriendsHubTests.cs +++ b/Govor.API.Tests/IntegrationTests/Hubs/FriendsHubTests.cs @@ -1,8 +1,12 @@ +using AutoFixture; +using AutoMapper; using Govor.API.Hubs; using Govor.Application.Exceptions.FriendsService; using Govor.Application.Interfaces.Friends; using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.Contracts.DTOs; using Govor.Contracts.Responses.SignalR; +using Govor.Core.Models; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; using Moq; @@ -17,26 +21,34 @@ public class FriendsHubTests private Mock _clientsMock = null!; private Mock _clientProxyMock = null!; private Mock> _loggerMock = null!; + private Mock _mapperMock = null!; private FriendsHub _hub = null!; + private Fixture _fixture; private readonly Guid _userId = Guid.NewGuid(); private readonly Guid _targetUserId = Guid.NewGuid(); [SetUp] public void Setup() { + _fixture = new Fixture(); + _fixture.Behaviors.OfType().ToList().ForEach(b => _fixture.Behaviors.Remove(b)); + _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); + _friendRequestServiceMock = new Mock(); _currentUserServiceMock = new Mock(); _clientsMock = new Mock(); _clientProxyMock = new Mock(); _loggerMock = new Mock>(); - + _mapperMock = new Mock(); + _currentUserServiceMock.Setup(x => x.GetCurrentUserId()).Returns(_userId); - _clientsMock.Setup(c => c.User(It.IsAny())).Returns(_clientProxyMock.Object); - + _clientsMock.Setup(c => c.Group(It.IsAny())).Returns(_clientProxyMock.Object); + _hub = new FriendsHub( _friendRequestServiceMock.Object, _currentUserServiceMock.Object, - _loggerMock.Object) + _loggerMock.Object, + _mapperMock.Object) { Clients = _clientsMock.Object }; @@ -46,6 +58,26 @@ public class FriendsHubTests [Test] public async Task SendRequest_ShouldReturnCreated_WhenSuccess() { + var friendship = _fixture.Build() + .With(f => f.Status, FriendshipStatus.Pending) + .With(f => f.RequesterId, _userId) + .With(f =>f.AddresseeId, _targetUserId) + .Create(); + + var dto = new FriendshipDto() + { + Id = friendship.Id, + Status = FriendshipStatus.Accepted, + AddresseeId = friendship.AddresseeId, + RequesterId = friendship.RequesterId, + }; + + _mapperMock.Setup(f => f.Map(friendship)) + .Returns(dto); + + _friendRequestServiceMock.Setup(f => f.SendAsync(_userId, _targetUserId)) + .ReturnsAsync(friendship); + // Act var result = await _hub.SendRequest(_targetUserId); @@ -57,7 +89,7 @@ public class FriendsHubTests _clientProxyMock.Verify(c => c.SendCoreAsync( "FriendRequestReceived", - It.Is(o => o[0]!.Equals(_userId)), + It.Is(o => o[0]!.Equals(dto)), default), Times.Once); } @@ -129,19 +161,35 @@ public class FriendsHubTests public async Task AcceptRequest_ShouldReturnOk_WhenSuccess() { // Arrange - var friendshipId = Guid.NewGuid(); + var friendship = _fixture.Build() + .With(f => f.Status, FriendshipStatus.Pending) + .Create(); + var dto = new FriendshipDto() + { + Id = friendship.Id, + Status = FriendshipStatus.Accepted, + AddresseeId = friendship.AddresseeId, + RequesterId = friendship.RequesterId + }; + + _mapperMock.Setup(f => f.Map(friendship)) + .Returns(dto); + + _friendRequestServiceMock.Setup(f => f.AcceptAsync(friendship.Id, _userId)) + .ReturnsAsync(friendship); // Act - var result = await _hub.AcceptRequest(friendshipId); + var result = await _hub.AcceptRequest(friendship.Id); // Assert Assert.That(result.Status, Is.EqualTo(HubResultStatus.Success)); - _friendRequestServiceMock.Verify(s => s.AcceptAsync(friendshipId, _userId), Times.Once); + _friendRequestServiceMock.Verify(s => s.AcceptAsync(friendship.Id, _userId), Times.Once); _clientProxyMock.Verify(c => c.SendCoreAsync( "FriendRequestAccepted", - It.Is(o => o[0]!.Equals(friendshipId)), + It.Is(o => o.Length == 1 && o[0] == dto), default), Times.Once); + } [Test] @@ -201,18 +249,33 @@ public class FriendsHubTests public async Task RejectRequest_ShouldReturnOk_WhenSuccess() { // Arrange - var friendshipId = Guid.NewGuid(); - + var friendship = _fixture.Build() + .With(f => f.Status, FriendshipStatus.Pending) + .Create(); + var dto = new FriendshipDto() + { + Id = friendship.Id, + Status = FriendshipStatus.Accepted, + AddresseeId = friendship.AddresseeId, + RequesterId = friendship.RequesterId + }; + + _mapperMock.Setup(f => f.Map(friendship)) + .Returns(dto); + + _friendRequestServiceMock.Setup(f => f.RejectAsync(friendship.Id, _userId)) + .ReturnsAsync(friendship); + // Act - var result = await _hub.RejectRequest(friendshipId); + var result = await _hub.RejectRequest(friendship.Id); // Assert Assert.That(result.Status, Is.EqualTo(HubResultStatus.Success)); - _friendRequestServiceMock.Verify(s => s.RejectAsync(friendshipId, _userId), Times.Once); + _friendRequestServiceMock.Verify(s => s.RejectAsync(friendship.Id, _userId), Times.Once); _clientProxyMock.Verify(c => c.SendCoreAsync( "FriendRequestRejected", - It.Is(o => o[0]!.Equals(friendshipId)), + It.Is(o => o[0]!.Equals(dto)), default), Times.Once); } diff --git a/Govor.API/Controllers/AdminStuff/FriendshipsController.cs b/Govor.API/Controllers/AdminStuff/FriendshipsController.cs index 502abf4..94b5225 100644 --- a/Govor.API/Controllers/AdminStuff/FriendshipsController.cs +++ b/Govor.API/Controllers/AdminStuff/FriendshipsController.cs @@ -29,7 +29,7 @@ public class FriendshipsController : Controller { _logger.LogInformation("Get all friendships by administrator"); var result = await _friendshipsRepository.GetAllAsync(); - return Ok(BuildFriendshipDtos(result)); + return Ok(result); } catch (NotFoundException ex) { @@ -53,7 +53,7 @@ public class FriendshipsController : Controller { _logger.LogInformation($"Get user's {userId} all friendships by administrator"); var result = await _friendshipsRepository.FindByUserIdAsync(userId); - return Ok(BuildFriendshipDtos(result)); + return Ok(result); } catch (NotFoundByKeyException ex) { @@ -89,21 +89,4 @@ public class FriendshipsController : Controller return StatusCode(500, new { error = "Internal server error." }); } } - - private List BuildUserDtos(IEnumerable users) => users.Select(user => new UserDto - { - Id = user.Id, - Username = user.Username, - Description = user.Description, - WasOnline = user.WasOnline, - IconId = user.IconId - }).ToList(); - - private List BuildFriendshipDtos(IEnumerable friendships) => friendships.Select(f => new FriendshipDto - { - Id = f.Id, - Status = f.Status, - AddresseeId = f.AddresseeId, - RequesterId = f.RequesterId, - }).ToList(); } \ No newline at end of file diff --git a/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs b/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs index 77deda3..f3f8bda 100644 --- a/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs +++ b/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs @@ -56,7 +56,11 @@ public class FriendsRequestQueryController : Controller { try { - var result = await _friendsService.GetResponsesAsync(_currentUserService.GetCurrentUserId()); + var userId = _currentUserService.GetCurrentUserId(); + + _logger.LogInformation("Getting responses by user {userId}", userId); + + var result = await _friendsService.GetResponsesAsync(userId); var response = _mapper.Map>(result); return Ok(response); diff --git a/Govor.API/Controllers/MediaController.cs b/Govor.API/Controllers/MediaController.cs index b7f00d8..d842430 100644 --- a/Govor.API/Controllers/MediaController.cs +++ b/Govor.API/Controllers/MediaController.cs @@ -1,4 +1,5 @@ using Govor.Application.Interfaces; +using Govor.Application.Interfaces.Infrastructure.Extensions; using Govor.Application.Interfaces.Medias; using Govor.Contracts.Requests; using Govor.Core.Models; @@ -15,27 +16,67 @@ public class MediaController : Controller { private readonly ILogger _logger; private readonly IMediaService _mediaService; - - public MediaController(ILogger logger, IMediaService mediaService) + private readonly ICurrentUserService _currentUserService; + + public MediaController( + ILogger logger, + IMediaService mediaService, + ICurrentUserService currentUserService) { _logger = logger; _mediaService = mediaService; + _currentUserService = currentUserService; } [HttpPost("upload")] - [RequestSizeLimit(100_000_000)]// ~100MB + [RequestSizeLimit(100_000_000)] // ~100MB public async Task Upload([FromForm] MediaUploadRequest request) { try { - var result = await _mediaService.UploadMediaAsync(new Media(request.Data, request.FileName, request.Type, - request.MimeType, request.EncryptedKey)); - - return Ok(result); + if (!ModelState.IsValid) + return BadRequest(ModelState); + + // Чтение байт из IFormFile + using var memoryStream = new MemoryStream(); + await request.Data.CopyToAsync(memoryStream); + byte[] fileBytes = memoryStream.ToArray(); + + var media = new Media( + _currentUserService.GetCurrentUserId(), + DateTime.UtcNow, + fileBytes, + request.FileName, + request.Type, + request.MimeType, + request.EncryptedKey + ); + + var result = await _mediaService.UploadMediaAsync(media); + return Ok(result); } catch (Exception ex) { - return StatusCode(500, ex); + _logger.LogError(ex, "Error uploading media"); + return StatusCode(500, "Internal server error"); } } -} \ No newline at end of file + + [HttpGet("download/{id}")] + public async Task Download(Guid id) + { + try + { + if (!ModelState.IsValid) + return BadRequest(ModelState); + + var media = await _mediaService.GetMediaByIdAsync(id); + return File(media.Data, media.MineType, media.FileName); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error downloading media"); + return StatusCode(500, "Internal server error"); + } + } +} diff --git a/Govor.API/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Extensions/ConfigurationProgramExtensions.cs index c0b5812..2689d8a 100644 --- a/Govor.API/Extensions/ConfigurationProgramExtensions.cs +++ b/Govor.API/Extensions/ConfigurationProgramExtensions.cs @@ -7,6 +7,7 @@ using Govor.Application.Interfaces; using Govor.Application.Interfaces.Authentication; using Govor.Application.Interfaces.Friends; using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.Application.Interfaces.Medias; using Govor.Application.Interfaces.Messages; using Govor.Application.Services; using Govor.Application.Services.Authentication; @@ -65,6 +66,7 @@ public static class ConfigurationProgramExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Auto Mapper services.AddAutoMapper(typeof(MappingProfile)); diff --git a/Govor.API/Hubs/ChatsHub.cs b/Govor.API/Hubs/ChatsHub.cs index 5db3c95..4869472 100644 --- a/Govor.API/Hubs/ChatsHub.cs +++ b/Govor.API/Hubs/ChatsHub.cs @@ -198,11 +198,25 @@ public class ChatsHub : Hub { var result = await _messageCommandService.EditMessageAsync(editMessageParam); - if (!result.IsSuccess) + if (!result.IsSuccess || result.OriginalMessage == null) return LogAndError(editor, request.MessageId, "Edit message error", result.Exception); - return HubResult.Ok(); + var response = new MessageEditResponse() + { + MessageId = result.messageId, + EditorId = editor, + RecipientId = result.OriginalMessage.RecipientId, + RecipientType = result.OriginalMessage.RecipientType, + NewEncryptedContent = request.NewEncryptedContent, + EditedAt = editMessageParam.EditedAt, + }; + + await NotifyClientsAboutEdit(response); + + _logger.LogInformation("Message {MessageId} edited successfully by {editor}", request.MessageId, editor); + + return HubResult.Ok(response); } catch (UnauthorizedAccessException ex) { @@ -259,6 +273,20 @@ public class ChatsHub : Hub } } + private async Task NotifyClientsAboutEdit(MessageEditResponse response) + { + if (response.RecipientType == RecipientType.User) + { + await Clients.Group(response.EditorId.ToString()).SendAsync("MessageEdit", response); + if (response.EditorId != response.RecipientId) + await Clients.Group(response.RecipientId.ToString()).SendAsync("MessageEdit", response); + } + else + { + await Clients.Group($"group_{response.RecipientId}").SendAsync("MessageEdit", response); + } + } + // Logging helpers private HubResult LogAndError(Guid userId, Guid targetId, string message, Exception ex) { diff --git a/Govor.API/Hubs/FriendsHub.cs b/Govor.API/Hubs/FriendsHub.cs index 1e9e309..b252080 100644 --- a/Govor.API/Hubs/FriendsHub.cs +++ b/Govor.API/Hubs/FriendsHub.cs @@ -1,6 +1,9 @@ +using System.ComponentModel.DataAnnotations; +using AutoMapper; using Govor.Application.Exceptions.FriendsService; using Govor.Application.Interfaces.Friends; using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.Contracts.DTOs; using Govor.Contracts.Responses.SignalR; using Microsoft.AspNetCore.SignalR; @@ -11,12 +14,17 @@ public class FriendsHub : Hub private readonly ILogger _logger; private readonly IFriendRequestCommandService _friendRequestService; private readonly ICurrentUserService _currentUserService; + private readonly IMapper _mapper; - public FriendsHub(IFriendRequestCommandService friendRequestService, ICurrentUserService currentUserService, ILogger logger) + public FriendsHub(IFriendRequestCommandService friendRequestService, + ICurrentUserService currentUserService, + ILogger logger, + IMapper mapper) { _friendRequestService = friendRequestService; _currentUserService = currentUserService; _logger = logger; + _mapper = mapper; } public override async Task OnConnectedAsync() @@ -70,9 +78,10 @@ public class FriendsHub : Hub try { var userId = _currentUserService.GetCurrentUserId(); - await _friendRequestService.SendAsync(userId, targetUserId); - await Clients.User(targetUserId.ToString()) - .SendAsync("FriendRequestReceived", userId); + var friendship = await _friendRequestService.SendAsync(userId, targetUserId); + + await Clients.Group(targetUserId.ToString()) + .SendAsync("FriendRequestReceived", _mapper.Map(friendship)); _logger.LogInformation($"Friend request received for user {targetUserId} from {userId}."); return HubResult.Created(); @@ -104,9 +113,9 @@ public class FriendsHub : Hub try { var userId = _currentUserService.GetCurrentUserId(); - await _friendRequestService.AcceptAsync(friendshipId, userId); - await Clients.User(userId.ToString()) - .SendAsync("FriendRequestAccepted", friendshipId); + var friendship = await _friendRequestService.AcceptAsync(friendshipId, userId); + await Clients.Group(userId.ToString()) + .SendAsync("FriendRequestAccepted", _mapper.Map(friendship)); _logger.LogInformation($"Friend request accepted for user {userId} from {userId}."); return HubResult.Ok(); @@ -133,9 +142,9 @@ public class FriendsHub : Hub try { var userId = _currentUserService.GetCurrentUserId(); - await _friendRequestService.RejectAsync(friendshipId, userId); - await Clients.User(userId.ToString()) - .SendAsync("FriendRequestRejected", friendshipId); + var friendship = await _friendRequestService.RejectAsync(friendshipId, userId); + await Clients.Group(userId.ToString()) + .SendAsync("FriendRequestRejected", _mapper.Map(friendship)); _logger.LogInformation($"Friend request rejected for user {userId} from {userId}."); return HubResult.Ok(); diff --git a/Govor.Application/Interfaces/Friends/IFriendRequestCommandService.cs b/Govor.Application/Interfaces/Friends/IFriendRequestCommandService.cs index 21f9d24..3d3c4e4 100644 --- a/Govor.Application/Interfaces/Friends/IFriendRequestCommandService.cs +++ b/Govor.Application/Interfaces/Friends/IFriendRequestCommandService.cs @@ -1,8 +1,10 @@ +using Govor.Core.Models; + namespace Govor.Application.Interfaces.Friends; public interface IFriendRequestCommandService { - Task SendAsync(Guid fromUserId, Guid toUserId); - Task AcceptAsync(Guid requestId, Guid currentUserId); - Task RejectAsync(Guid requestId, Guid currentUserId); + Task SendAsync(Guid fromUserId, Guid toUserId); + Task AcceptAsync(Guid requestId, Guid currentUserId); + Task RejectAsync(Guid requestId, Guid currentUserId); } diff --git a/Govor.Application/Interfaces/Medias/IMediaService.cs b/Govor.Application/Interfaces/Medias/IMediaService.cs index 6b0e3b3..48a8232 100644 --- a/Govor.Application/Interfaces/Medias/IMediaService.cs +++ b/Govor.Application/Interfaces/Medias/IMediaService.cs @@ -6,9 +6,16 @@ public interface IMediaService { public Task UploadMediaAsync(Media file); public Task DeleteMediaAsync(Guid fileId); - public Task GetMediaAsync(string url); + public Task GetMediaByUrlAsync(string url); + public Task GetMediaByIdAsync(Guid mediaId); } -public record Media(byte[] Data, string FileName, MediaType Type, string MineType, string EncryptedKey); +public record Media(Guid UploaderId, + DateTime UploadedOn, + byte[] Data, + string FileName, + MediaType Type, + string MineType, + string EncryptedKey); public record MediaUploadResult(Guid? MediaId, string Url); \ No newline at end of file diff --git a/Govor.Application/Services/Friends/FriendRequestCommandService.cs b/Govor.Application/Services/Friends/FriendRequestCommandService.cs index e4e8780..3e6b4bb 100644 --- a/Govor.Application/Services/Friends/FriendRequestCommandService.cs +++ b/Govor.Application/Services/Friends/FriendRequestCommandService.cs @@ -15,7 +15,7 @@ public class FriendRequestCommandService : IFriendRequestCommandService _friendshipsRepository = friendshipsRepository; } - public async Task SendAsync(Guid fromUserId, Guid toUserId) + public async Task SendAsync(Guid fromUserId, Guid toUserId) { if (fromUserId == toUserId) throw new InvalidOperationException("Cannot send a request to self user"); @@ -23,16 +23,20 @@ public class FriendRequestCommandService : IFriendRequestCommandService if (_friendshipsRepository.Exist(fromUserId, toUserId)) throw new RequestAlreadySentException(fromUserId, toUserId); - await _friendshipsRepository.AddAsync(new Friendship + var friendship = new Friendship { Id = Guid.NewGuid(), RequesterId = fromUserId, AddresseeId = toUserId, Status = FriendshipStatus.Pending - }); + }; + + await _friendshipsRepository.AddAsync(friendship); + + return friendship; } - public async Task AcceptAsync(Guid requestId, Guid currentUserId) + public async Task AcceptAsync(Guid requestId, Guid currentUserId) { try { @@ -46,6 +50,8 @@ public class FriendRequestCommandService : IFriendRequestCommandService friendship.Status = FriendshipStatus.Accepted; await _friendshipsRepository.UpdateAsync(friendship); + + return friendship; } catch (NotFoundByKeyException ex) { @@ -53,7 +59,7 @@ public class FriendRequestCommandService : IFriendRequestCommandService } } - public async Task RejectAsync(Guid requestId, Guid currentUserId) + public async Task RejectAsync(Guid requestId, Guid currentUserId) { try { @@ -67,6 +73,7 @@ public class FriendRequestCommandService : IFriendRequestCommandService friendship.Status = FriendshipStatus.Rejected; await _friendshipsRepository.UpdateAsync(friendship); + return friendship; } catch (NotFoundByKeyException ex) { diff --git a/Govor.Application/Services/Messages/MediaService.cs b/Govor.Application/Services/Messages/MediaService.cs index 46df591..21bba28 100644 --- a/Govor.Application/Services/Messages/MediaService.cs +++ b/Govor.Application/Services/Messages/MediaService.cs @@ -1,20 +1,53 @@ using Govor.Application.Interfaces; using Govor.Application.Interfaces.Medias; +using Govor.Core.Models; +using Govor.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; namespace Govor.Application.Services.Messages; public class MediaService : IMediaService { + private ILogger _logger; private IStorageService _storageService; + private GovorDbContext _dbContext; - public MediaService(IStorageService storageService) + public MediaService(IStorageService storageService, GovorDbContext dbContext, ILogger logger) { _storageService = storageService; + _dbContext = dbContext; + _logger = logger; } - public Task UploadMediaAsync(Media file) + public async Task UploadMediaAsync(Media file) { - throw new NotImplementedException(); + try + { + var url = await _storageService.SaveAsync(file.Data, file.FileName); + + var mediaId = Guid.NewGuid(); + + _dbContext.MediaFiles.Add(new MediaFile() + { + Id = mediaId, + UploaderId = file.UploaderId, + DateCreated = file.UploadedOn, + MediaType = file.Type, + MineType = file.MineType, + Url = url + }); + + await _dbContext.SaveChangesAsync(); + + _logger.LogInformation($"Media uploaded: {url} with id: {mediaId} by {file.UploaderId}"); + + return new MediaUploadResult(mediaId, url); + } + catch (ArgumentException ex) + { + throw new InvalidOperationException($"An error occured while uploading the media file: {ex.Message}"); + } } public Task DeleteMediaAsync(Guid fileId) @@ -22,8 +55,46 @@ public class MediaService : IMediaService throw new NotImplementedException(); } - public Task GetMediaAsync(string url) + public Task GetMediaByUrlAsync(string url) { throw new NotImplementedException(); } + + public async Task GetMediaByIdAsync(Guid mediaId) + { + try + { + var mediaFile = await _dbContext.MediaFiles + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == mediaId) + ?? throw new KeyNotFoundException("No media found"); + + // Загрузить бинарные данные из хранилища + Stream dataStream = await _storageService.LoadAsync(mediaFile.Url); + + // Считать поток в byte[] + using var memoryStream = new MemoryStream(); + await dataStream.CopyToAsync(memoryStream); + var contentBytes = memoryStream.ToArray(); + + _logger.LogInformation($"Media found: {mediaFile.MediaType} with id: {mediaFile.Id} and url: {mediaFile.Url}"); + + // Вернуть объект Media + return new Media( + mediaFile.UploaderId, + mediaFile.DateCreated, + contentBytes, + string.Empty, + mediaFile.MediaType, + mediaFile.MineType, + string.Empty + ); + } + catch (FileNotFoundException ex) + { + _logger.LogWarning(ex, "Media file not found on storage."); + throw; + } + } + } \ No newline at end of file diff --git a/Govor.Console/Commands/AcceptFriendRequestCommand.cs b/Govor.Console/Commands/AcceptFriendRequestCommand.cs deleted file mode 100644 index a81be53..0000000 --- a/Govor.Console/Commands/AcceptFriendRequestCommand.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System; -using System.Threading.Tasks; -using Microsoft.AspNetCore.SignalR.Client; - -namespace Govor.ConsoleClient.Commands -{ - public class AcceptFriendRequestCommand : BaseCommand - { - public override async Task ExecuteAsync(string? argument) - { - if (!EnsureLoggedIn() || !EnsureHubConnection()) return; - - Guid friendshipId; - if (string.IsNullOrWhiteSpace(argument) || !Guid.TryParse(argument, out friendshipId)) - { - Console.Write("Введите ID заявки, которую хотите принять: "); - var input = Console.ReadLine(); - if (string.IsNullOrWhiteSpace(input) || !Guid.TryParse(input, out friendshipId)) - { - Console.WriteLine("[Ошибка] Неверный или пустой ID заявки."); - return; - } - } - - try - { - // API uses Hub for this: AcceptFriendRequest(Guid friendshipId) - await HubConnection.InvokeAsync("AcceptRequest", friendshipId); - Console.WriteLine("Заявка в друзья принята."); - } - catch (Exception ex) - { - Console.WriteLine($"[Ошибка принятия заявки] {ex.Message}"); - } - } - - public override string GetHelp() - { - return "/accept [ID_заявки] - Принять входящую заявку в друзья. Если ID не указан, запросит ввод."; - } - } -} diff --git a/Govor.Console/Commands/AdminListAllFriendshipsCommand.cs b/Govor.Console/Commands/AdminListAllFriendshipsCommand.cs deleted file mode 100644 index 226c547..0000000 --- a/Govor.Console/Commands/AdminListAllFriendshipsCommand.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using System.Net.Http.Json; -using System.Text.Json; -using System.Threading.Tasks; -using Govor.Contracts.DTOs; // Required for FriendshipDto - -namespace Govor.ConsoleClient.Commands -{ - public class AdminListAllFriendshipsCommand : BaseCommand - { - public override async Task ExecuteAsync(string? argument) - { - if (!EnsureLoggedIn()) return; - // Consider adding an admin role check here if possible, - // though the API itself is protected by [Authorize(Roles = "Admin")] - - Console.WriteLine("Получение всех дружеских связей (только для администраторов)..."); - try - { - var json = await HttpClientService.GetAsync("api/admin/Friendships"); - - var friendships = JsonSerializer.Deserialize>(json, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); - - if (friendships != null && friendships.Any()) - { - Console.WriteLine("Все дружеские связи в системе:"); - foreach (var f in friendships) - { - Console.WriteLine($"- ID: {f.Id}, Пользователь1: {f.RequesterId}, Пользователь2: {f.AddresseeId}, Статус: {f.Status}"); - } - } - else - { - Console.WriteLine("Дружеские связи не найдены в системе."); - } - } - catch (HttpRequestException httpEx) when (httpEx.StatusCode == System.Net.HttpStatusCode.Forbidden) - { - Console.WriteLine("[Ошибка] Доступ запрещен. Эта команда только для администраторов."); - } - catch (Exception ex) - { - Console.WriteLine($"[Ошибка] {ex.Message}"); - } - } - - public override string GetHelp() - { - return "/adminlistallfs - (Админ) Показать все дружеские связи в системе."; - } - } -} diff --git a/Govor.Console/Commands/AdminListUserFriendshipsCommand.cs b/Govor.Console/Commands/AdminListUserFriendshipsCommand.cs deleted file mode 100644 index 96b4539..0000000 --- a/Govor.Console/Commands/AdminListUserFriendshipsCommand.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using System.Net.Http.Json; -using System.Text.Json; -using System.Threading.Tasks; -using Govor.Contracts.DTOs; // Required for FriendshipDto - -namespace Govor.ConsoleClient.Commands -{ - public class AdminListUserFriendshipsCommand : BaseCommand - { - public override async Task ExecuteAsync(string? argument) - { - if (!EnsureLoggedIn()) return; - - Guid userId; - if (string.IsNullOrWhiteSpace(argument) || !Guid.TryParse(argument, out userId)) - { - Console.Write("Введите ID пользователя для просмотра его связей: "); - var input = Console.ReadLine(); - if (string.IsNullOrWhiteSpace(input) || !Guid.TryParse(input, out userId)) - { - Console.WriteLine("[Ошибка] Неверный или пустой ID пользователя."); - return; - } - } - - Console.WriteLine($"Получение всех связей для пользователя {userId} (только для администраторов)..."); - try - { - var response = await HttpClientService.GetAsync($"api/admin/Friendships/{userId}"); - - var friendships = JsonSerializer.Deserialize>(response); - - if (friendships != null && friendships.Any()) - { - Console.WriteLine($"Дружеские связи для пользователя {userId}:"); - foreach (var f in friendships) - { - Console.WriteLine($"- ID: {f.Id}, Пользователь1: {f.RequesterId}, Пользователь2: {f.AddresseeId}, Статус: {f.Status}"); - } - } - else - { - Console.WriteLine($"Дружеские связи для пользователя {userId} не найдены."); - } - } - catch (HttpRequestException httpEx) when (httpEx.StatusCode == System.Net.HttpStatusCode.Forbidden) - { - Console.WriteLine("[Ошибка] Доступ запрещен. Эта команда только для администраторов."); - } - catch (Exception ex) - { - Console.WriteLine($"[Ошибка] {ex.Message}"); - } - } - - public override string GetHelp() - { - return "/adminlistuserfs [ID_пользователя] - (Админ) Показать все дружеские связи для указанного пользователя. Если ID не указан, запросит ввод."; - } - } -} diff --git a/Govor.Console/Commands/AdminRemoveFriendshipCommand.cs b/Govor.Console/Commands/AdminRemoveFriendshipCommand.cs deleted file mode 100644 index aa55ff9..0000000 --- a/Govor.Console/Commands/AdminRemoveFriendshipCommand.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System; -using System.Net.Http; -using System.Threading.Tasks; - -namespace Govor.ConsoleClient.Commands -{ - public class AdminRemoveFriendshipCommand : BaseCommand - { - public override async Task ExecuteAsync(string? argument) - { - if (!EnsureLoggedIn()) return; - - Guid friendshipId; - if (string.IsNullOrWhiteSpace(argument) || !Guid.TryParse(argument, out friendshipId)) - { - Console.Write("Введите ID дружеской связи для удаления: "); - var input = Console.ReadLine(); - if (string.IsNullOrWhiteSpace(input) || !Guid.TryParse(input, out friendshipId)) - { - Console.WriteLine("[Ошибка] Неверный или пустой ID связи."); - return; - } - } - - Console.WriteLine($"Удаление дружеской связи {friendshipId} (только для администраторов)..."); - try - { - // The API endpoint is: [HttpPost] public async Task RemoveFriendship(Guid Id) - // It expects the Id in the query string, like: api/admin/Friendships?Id=GUID - // Or it might be a POST with x-www-form-urlencoded data or JSON body, need to check API implementation. - // Based on `[HttpPost] public async Task RemoveFriendship(Guid Id)`, it's likely a query parameter or form data. - // Let's assume query parameter for now as it's simpler for HttpPost. - - var response = await HttpClientService.PostAsync($"api/admin/Friendships?Id={friendshipId}", null); - - Console.WriteLine($"Дружеская связь {friendshipId} успешно удалена."); - } - catch (HttpRequestException httpEx) when (httpEx.StatusCode == System.Net.HttpStatusCode.Forbidden) - { - Console.WriteLine("[Ошибка] Доступ запрещен. Эта команда только для администраторов."); - } - catch (HttpRequestException httpEx) when (httpEx.StatusCode == System.Net.HttpStatusCode.NotFound) - { - Console.WriteLine($"[Ошибка] Дружеская связь с ID {friendshipId} не найдена."); - } - catch (Exception ex) - { - Console.WriteLine($"[Ошибка] {ex.Message}"); - } - } - - public override string GetHelp() - { - return "/adminremovefs [ID_связи] - (Админ) Удалить дружескую связь по ее ID. Если ID не указан, запросит ввод."; - } - } -} diff --git a/Govor.Console/Commands/BaseCommand.cs b/Govor.Console/Commands/BaseCommand.cs deleted file mode 100644 index 9706fbe..0000000 --- a/Govor.Console/Commands/BaseCommand.cs +++ /dev/null @@ -1,65 +0,0 @@ -using Microsoft.AspNetCore.SignalR.Client; -using System; -using System.Threading.Tasks; - -namespace Govor.ConsoleClient.Commands -{ - public abstract class BaseCommand : ICommand - { - protected static FriendsClient? FriendsClient { get; private set; } - protected static HttpClientService HttpClientService { get; private set; } = null!; - protected static Func GetAuthToken { get; private set; } = null!; - protected static Action SetAuthToken { get; private set; } = null!; - protected static Func InitializeHubConnectionAsync { get; private set; } = null!; - protected static HubConnection HubConnection => _getHubConnection?.Invoke(); - - private static Func _getHubConnection = null!; - - public static void InitializeServices( - FriendsClient? friendsClient, - HttpClientService httpClientService, - Func getAuthToken, - Action setAuthToken, - Func initializeHubConnectionAsync, - Func getHubConnection) - { - FriendsClient = friendsClient; - HttpClientService = httpClientService; - GetAuthToken = getAuthToken; - SetAuthToken = setAuthToken; - InitializeHubConnectionAsync = initializeHubConnectionAsync; - _getHubConnection = getHubConnection; - } - - public abstract Task ExecuteAsync(string? argument); - public abstract string GetHelp(); - - protected bool EnsureLoggedIn() - { - if (GetAuthToken() == null) - { - Console.WriteLine("[Ошибка] Сначала войдите в систему. Используйте /login или /reg."); - return false; - } - - if (FriendsClient == null && !(this is LoginCommand || this is RegisterCommand)) - { - Console.WriteLine("[Ошибка] Клиент для работы с друзьями не инициализирован. Попробуйте войти снова."); - return false; - } - - return true; - } - - protected bool EnsureHubConnection() - { - var hub = _getHubConnection?.Invoke(); - if (hub == null || hub.State != HubConnectionState.Connected) - { - Console.WriteLine("[Ошибка] SignalR соединение не установлено. Попробуйте войти снова или проверьте соединение."); - return false; - } - return true; - } - } -} diff --git a/Govor.Console/Commands/BlockUserCommand.cs b/Govor.Console/Commands/BlockUserCommand.cs deleted file mode 100644 index bf83a39..0000000 --- a/Govor.Console/Commands/BlockUserCommand.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; -using System.Threading.Tasks; -using Microsoft.AspNetCore.SignalR.Client; - -namespace Govor.ConsoleClient.Commands -{ - public class BlockUserCommand : BaseCommand - { - public override async Task ExecuteAsync(string? argument) - { - if (!EnsureLoggedIn() || !EnsureHubConnection()) return; - - Guid targetUserId; - if (string.IsNullOrWhiteSpace(argument) || !Guid.TryParse(argument, out targetUserId)) - { - Console.Write("Введите ID пользователя, которого хотите заблокировать: "); - var input = Console.ReadLine(); - if (string.IsNullOrWhiteSpace(input) || !Guid.TryParse(input, out targetUserId)) - { - Console.WriteLine("[Ошибка] Неверный или пустой ID пользователя."); - return; - } - } - - try - { - // API uses Hub for this: BlockUser(Guid targetUserId) - // Located in Govor.API/Hubs/FriendsHub.cs - await HubConnection.InvokeAsync("BlockUser", targetUserId); - Console.WriteLine($"Пользователь {targetUserId} заблокирован."); - } - catch (Exception ex) - { - Console.WriteLine($"[Ошибка блокировки пользователя] {ex.Message}"); - } - } - - public override string GetHelp() - { - return "/block [ID_пользователя] - Заблокировать пользователя. Если ID не указан, запросит ввод."; - } - } -} diff --git a/Govor.Console/Commands/HelpCommand.cs b/Govor.Console/Commands/HelpCommand.cs deleted file mode 100644 index c331a83..0000000 --- a/Govor.Console/Commands/HelpCommand.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -namespace Govor.ConsoleClient.Commands -{ - public class HelpCommand : BaseCommand - { - private readonly Dictionary _availableCommands; - - // The HelpCommand needs access to the list of all commands to display their help messages. - // This will be injected from Program.cs where the commands are registered. - public HelpCommand(Dictionary availableCommands) - { - _availableCommands = availableCommands; - } - - public override Task ExecuteAsync(string? argument) - { - Console.WriteLine("Доступные команды:"); - if (!string.IsNullOrWhiteSpace(argument)) - { - string commandKey = argument.StartsWith("/") ? argument : "/" + argument; - if (_availableCommands.TryGetValue(commandKey.ToLower(), out var specificCommand)) - { - Console.WriteLine(specificCommand.GetHelp()); - } - else - { - Console.WriteLine($"Команда '{argument}' не найдена. Введите /help для списка всех команд."); - } - } - else - { - foreach (var commandEntry in _availableCommands.OrderBy(c => c.Key)) - { - Console.WriteLine(commandEntry.Value.GetHelp()); - } - } - return Task.CompletedTask; - } - - public override string GetHelp() - { - return "/help [команда] - Показать список всех команд или помощь по конкретной команде."; - } - } -} diff --git a/Govor.Console/Commands/ICommand.cs b/Govor.Console/Commands/ICommand.cs deleted file mode 100644 index 0e3c8ae..0000000 --- a/Govor.Console/Commands/ICommand.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Govor.ConsoleClient.Commands -{ - public interface ICommand - { - Task ExecuteAsync(string? argument); - string GetHelp(); - } -} diff --git a/Govor.Console/Commands/ListFriendsCommand.cs b/Govor.Console/Commands/ListFriendsCommand.cs deleted file mode 100644 index 1d4cf92..0000000 --- a/Govor.Console/Commands/ListFriendsCommand.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; -using System.Linq; -using System.Threading.Tasks; - -namespace Govor.ConsoleClient.Commands -{ - public class ListFriendsCommand : BaseCommand - { - public override async Task ExecuteAsync(string? argument) - { - if (!EnsureLoggedIn()) return; - - try - { - var friends = await FriendsClient.GetFriendsAsync(); - if (friends.Any()) - { - Console.WriteLine("Ваши друзья:"); - foreach (var f in friends) - { - Console.WriteLine($"- {f.Username} | был онлайн: {f.WasOnline} [{f.Id}]"); - } - } - else - { - Console.WriteLine("У вас пока нет друзей."); - } - } - catch (Exception ex) - { - Console.WriteLine($"[Ошибка] {ex.Message}"); - } - } - - public override string GetHelp() - { - return "/friends - Показать список ваших друзей."; - } - } -} diff --git a/Govor.Console/Commands/ListIncomingRequestsCommand.cs b/Govor.Console/Commands/ListIncomingRequestsCommand.cs deleted file mode 100644 index 3bfa6a7..0000000 --- a/Govor.Console/Commands/ListIncomingRequestsCommand.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; -using System.Linq; -using System.Threading.Tasks; - -namespace Govor.ConsoleClient.Commands -{ - public class ListIncomingRequestsCommand : BaseCommand - { - public override async Task ExecuteAsync(string? argument) - { - if (!EnsureLoggedIn()) return; - - try - { - // This still uses REST client as per existing FriendsClient - var requests = await FriendsClient.GetIncomingRequestsAsync(); - if (requests.Any()) - { - Console.WriteLine("Входящие заявки в друзья:"); - foreach (var r in requests) - { - // Assuming you want to show who sent the request. - // The FriendshipDto contains RequesterId and AddresseeId. - // If current user is AddresseeId, then RequesterId is the one who sent it. - Console.WriteLine($"- Запрос ID: {r.Id}. От пользователя ID: {r.RequesterId}. Статус: {r.Status}. (принять через /accept {r.Id})"); - } - } - else - { - Console.WriteLine("У вас нет входящих заявок в друзья."); - } - } - catch (Exception ex) - { - Console.WriteLine($"[Ошибка] {ex.Message}"); - } - } - - public override string GetHelp() - { - return "/incoming - Показать список входящих заявок в друзья."; - } - } -} diff --git a/Govor.Console/Commands/ListOutgoingRequestsCommand.cs b/Govor.Console/Commands/ListOutgoingRequestsCommand.cs deleted file mode 100644 index ffa272d..0000000 --- a/Govor.Console/Commands/ListOutgoingRequestsCommand.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System; -using System.Linq; -using System.Net.Http; -using System.Net.Http.Json; -using System.Text.Json; -using System.Threading.Tasks; -using Govor.Contracts.DTOs; // Required for FriendshipDto - -namespace Govor.ConsoleClient.Commands -{ - public class ListOutgoingRequestsCommand : BaseCommand - { - public override async Task ExecuteAsync(string? argument) - { - if (!EnsureLoggedIn()) return; - - try - { - // This functionality maps to GET api/friends/responses in FriendsRequestQueryController - // FriendsClient doesn't have a dedicated method, so we use HttpClientService or direct HttpClient - var json = await HttpClientService.GetAsync("api/friends/responses"); - - var requests = JsonSerializer.Deserialize>(json, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); - - if (requests != null && requests.Any()) - { - Console.WriteLine("Отправленные вами заявки в друзья (ожидают ответа):"); - foreach (var r in requests) - { - // If current user is RequesterId, then AddresseeId is the one they sent to. - Console.WriteLine($"- Запрос ID: {r.Id}. Пользователю ID: {r.AddresseeId}. Статус: {r.Status}."); - } - } - else - { - Console.WriteLine("У вас нет отправленных заявок, ожидающих ответа."); - } - } - catch (Exception ex) - { - Console.WriteLine($"[Ошибка] {ex.Message}"); - } - } - - public override string GetHelp() - { - return "/outgoing - Показать список отправленных вами заявок в друзья, ожидающих ответа."; - } - } -} diff --git a/Govor.Console/Commands/LoginCommand.cs b/Govor.Console/Commands/LoginCommand.cs deleted file mode 100644 index 9f2d017..0000000 --- a/Govor.Console/Commands/LoginCommand.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System; -using System.Net.Http.Headers; -using System.Threading.Tasks; - -namespace Govor.ConsoleClient.Commands -{ - public class LoginCommand : BaseCommand - { - public override async Task ExecuteAsync(string? argument) - { - Console.Write("username: "); - var loginUsername = Console.ReadLine(); - - Console.Write("password: "); - var loginPassword = Console.ReadLine(); - - if (string.IsNullOrWhiteSpace(loginUsername) || string.IsNullOrWhiteSpace(loginPassword)) - { - Console.WriteLine("[Ошибка] Имя пользователя и пароль не могут быть пустыми."); - return; - } - - try - { - var authToken = await HttpClientService.LoginAsync(loginUsername, loginPassword); - SetAuthToken(authToken); - - // Initialize FriendsClient after successful login - var sharedClient = new HttpClient { BaseAddress = new Uri(HttpClientService.GetBaseUrl()) }; - sharedClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", authToken); - var friendsClient = new FriendsClient(sharedClient); - - - - Program.UpdateFriendsClient(friendsClient); // <-- единственный нужный вызов - - await InitializeHubConnectionAsync(); - Console.WriteLine("[Успех] Вход выполнен. Токен сохранен."); - } - catch (Exception ex) - { - Console.WriteLine($"[Ошибка] {ex.Message}"); - } - } - - public override string GetHelp() - { - return "/login - Войти в существующий аккаунт."; - } - } -} diff --git a/Govor.Console/Commands/RegisterCommand.cs b/Govor.Console/Commands/RegisterCommand.cs deleted file mode 100644 index 2dd84e7..0000000 --- a/Govor.Console/Commands/RegisterCommand.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System; -using System.Net.Http.Headers; -using System.Threading.Tasks; - -namespace Govor.ConsoleClient.Commands -{ - public class RegisterCommand : BaseCommand - { - public override async Task ExecuteAsync(string? argument) - { - Console.Write("username: "); - var regUsername = Console.ReadLine(); - - Console.Write("password: "); - var regPassword = Console.ReadLine(); - - Console.Write("invitation code: "); - var inviteCode = Console.ReadLine(); - - if (string.IsNullOrWhiteSpace(regUsername) || string.IsNullOrWhiteSpace(regPassword) || string.IsNullOrWhiteSpace(inviteCode)) - { - Console.WriteLine("[Ошибка] Имя пользователя, пароль и код приглашения не могут быть пустыми."); - return; - } - - try - { - var authToken = await HttpClientService.RegisterAsync(regUsername, regPassword, inviteCode); - SetAuthToken(authToken); - - // Initialize FriendsClient after successful registration - var sharedClient = new HttpClient { BaseAddress = new Uri(HttpClientService.GetBaseUrl()) }; - sharedClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", authToken); - var friendsClient = new FriendsClient(sharedClient); - - // Re-initialize services in BaseCommand with the new FriendsClient - Program.UpdateFriendsClient(friendsClient); // <-- единственный нужный вызов - - await InitializeHubConnectionAsync(); - Console.WriteLine("[Успех] Регистрация завершена. Токен сохранен."); - } - catch (Exception ex) - { - Console.WriteLine($"[Ошибка] {ex.Message}"); - } - } - - public override string GetHelp() - { - return "/reg - Зарегистрировать новый аккаунт с помощью кода приглашения."; - } - } -} diff --git a/Govor.Console/Commands/RejectFriendRequestCommand.cs b/Govor.Console/Commands/RejectFriendRequestCommand.cs deleted file mode 100644 index afd9673..0000000 --- a/Govor.Console/Commands/RejectFriendRequestCommand.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; -using System.Threading.Tasks; -using Microsoft.AspNetCore.SignalR.Client; - -namespace Govor.ConsoleClient.Commands -{ - public class RejectFriendRequestCommand : BaseCommand - { - public override async Task ExecuteAsync(string? argument) - { - if (!EnsureLoggedIn() || !EnsureHubConnection()) return; - - Guid friendshipId; - if (string.IsNullOrWhiteSpace(argument) || !Guid.TryParse(argument, out friendshipId)) - { - Console.Write("Введите ID заявки, которую хотите отклонить: "); - var input = Console.ReadLine(); - if (string.IsNullOrWhiteSpace(input) || !Guid.TryParse(input, out friendshipId)) - { - Console.WriteLine("[Ошибка] Неверный или пустой ID заявки."); - return; - } - } - - try - { - // API uses Hub for this: RejectFriendRequest(Guid friendshipId) - // Located in Govor.API/Hubs/FriendsHub.cs - await HubConnection.InvokeAsync("RejectRequest", friendshipId); - Console.WriteLine("Заявка в друзья отклонена."); - } - catch (Exception ex) - { - Console.WriteLine($"[Ошибка отклонения заявки] {ex.Message}"); - } - } - - public override string GetHelp() - { - return "/reject [ID_заявки] - Отклонить входящую заявку в друзья. Если ID не указан, запросит ввод."; - } - } -} diff --git a/Govor.Console/Commands/SearchUserCommand.cs b/Govor.Console/Commands/SearchUserCommand.cs deleted file mode 100644 index 1c00d31..0000000 --- a/Govor.Console/Commands/SearchUserCommand.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System; -using System.Linq; -using System.Threading.Tasks; - -namespace Govor.ConsoleClient.Commands -{ - public class SearchUserCommand : BaseCommand - { - public override async Task ExecuteAsync(string? argument) - { - if (!EnsureLoggedIn()) return; - - if (string.IsNullOrWhiteSpace(argument)) - { - Console.Write("Введите имя пользователя для поиска: "); - argument = Console.ReadLine(); - if (string.IsNullOrWhiteSpace(argument)) - { - Console.WriteLine("[Ошибка] Поисковый запрос не может быть пустым."); - return; - } - } - - try - { - var foundUsers = await FriendsClient.SearchAsync(argument); - if (foundUsers.Any()) - { - Console.WriteLine("Найденные пользователи:"); - foreach (var user in foundUsers) - { - Console.WriteLine($"- {user.Username} [{user.Id}]"); - } - } - else - { - Console.WriteLine("Пользователи не найдены."); - } - } - catch (Exception ex) - { - Console.WriteLine($"[Ошибка] {ex.Message}"); - } - } - - public override string GetHelp() - { - return "/search [имя_пользователя] - Найти пользователей по имени. Если имя не указано, запросит ввод."; - } - } -} diff --git a/Govor.Console/Commands/SendFriendRequestCommand.cs b/Govor.Console/Commands/SendFriendRequestCommand.cs deleted file mode 100644 index 62c65fe..0000000 --- a/Govor.Console/Commands/SendFriendRequestCommand.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System; -using System.Threading.Tasks; -using Microsoft.AspNetCore.SignalR.Client; - -namespace Govor.ConsoleClient.Commands -{ - public class SendFriendRequestCommand : BaseCommand - { - public override async Task ExecuteAsync(string? argument) - { - if (!EnsureLoggedIn() || !EnsureHubConnection()) return; - - Guid targetUserId; - if (string.IsNullOrWhiteSpace(argument) || !Guid.TryParse(argument, out targetUserId)) - { - Console.Write("Введите ID пользователя, которому хотите отправить запрос: "); - var input = Console.ReadLine(); - if (string.IsNullOrWhiteSpace(input) || !Guid.TryParse(input, out targetUserId)) - { - Console.WriteLine("[Ошибка] Неверный или пустой ID пользователя."); - return; - } - } - - try - { - // API uses Hub for this: SendFriendRequest(Guid targetUserId) - await HubConnection.InvokeAsync("SendRequest", targetUserId); - Console.WriteLine("Запрос в друзья отправлен."); - } - catch (Exception ex) - { - Console.WriteLine($"[Ошибка отправки запроса] {ex.Message}"); - } - } - - public override string GetHelp() - { - return "/friend [ID_пользователя] - Отправить запрос в друзья пользователю с указанным ID. Если ID не указан, запросит ввод."; - } - } -} diff --git a/Govor.Console/Commands/UnblockUserCommand.cs b/Govor.Console/Commands/UnblockUserCommand.cs deleted file mode 100644 index 5fa17fc..0000000 --- a/Govor.Console/Commands/UnblockUserCommand.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; -using System.Threading.Tasks; -using Microsoft.AspNetCore.SignalR.Client; - -namespace Govor.ConsoleClient.Commands -{ - public class UnblockUserCommand : BaseCommand - { - public override async Task ExecuteAsync(string? argument) - { - if (!EnsureLoggedIn() || !EnsureHubConnection()) return; - - Guid targetUserId; - if (string.IsNullOrWhiteSpace(argument) || !Guid.TryParse(argument, out targetUserId)) - { - Console.Write("Введите ID пользователя, которого хотите разблокировать: "); - var input = Console.ReadLine(); - if (string.IsNullOrWhiteSpace(input) || !Guid.TryParse(input, out targetUserId)) - { - Console.WriteLine("[Ошибка] Неверный или пустой ID пользователя."); - return; - } - } - - try - { - // API uses Hub for this: UnblockUser(Guid targetUserId) - // Located in Govor.API/Hubs/FriendsHub.cs - await HubConnection.InvokeAsync("UnblockUser", targetUserId); - Console.WriteLine($"Пользователь {targetUserId} разблокирован."); - } - catch (Exception ex) - { - Console.WriteLine($"[Ошибка разблокировки пользователя] {ex.Message}"); - } - } - - public override string GetHelp() - { - return "/unblock [ID_пользователя] - Разблокировать пользователя. Если ID не указан, запросит ввод."; - } - } -} diff --git a/Govor.Console/FriendsHubClient.cs b/Govor.Console/FriendsHubClient.cs index f8667d2..fdc342b 100644 --- a/Govor.Console/FriendsHubClient.cs +++ b/Govor.Console/FriendsHubClient.cs @@ -1,157 +1,70 @@ +using Microsoft.AspNetCore.SignalR.Client; using System; using System.Threading.Tasks; -using Microsoft.AspNetCore.SignalR; -using Microsoft.AspNetCore.SignalR.Client; +using Govor.Contracts.Responses.SignalR; -class FriendsHubClient +namespace Govor.ConsoleClient; + + +public class FriendsHubClient { - private readonly HubConnection _connection; + private readonly string _hubUrl; + private readonly string _jwtToken; + private HubConnection _connection; public FriendsHubClient(string hubUrl, string jwtToken) + { + _hubUrl = hubUrl; + _jwtToken = jwtToken; + } + + public async Task ConnectAsync() { _connection = new HubConnectionBuilder() - .WithUrl(hubUrl, options => + .WithUrl($"{_hubUrl}/friends", options => { - options.AccessTokenProvider = () => Task.FromResult(jwtToken); + options.AccessTokenProvider = () => Task.FromResult(_jwtToken); }) .WithAutomaticReconnect() .Build(); - // Подписка на события от сервера - _connection.On("FriendRequestReceived", OnFriendRequestReceived); - _connection.On("FriendRequestAccepted", OnFriendRequestAccepted); - _connection.On("FriendRequestRejected", OnFriendRequestRejected); - } + // Событие: получен входящий запрос в друзья + _connection.On("FriendRequestReceived", userId => + { + Console.WriteLine($"📨 Friend request received from: {userId}"); + }); - private void OnFriendRequestReceived(Guid userId) - { - Console.WriteLine($"[Event] Получен запрос в друзья от пользователя: {userId}"); - } + // Событие: запрос принят + _connection.On("FriendRequestAccepted", friendshipId => + { + Console.WriteLine($"✅ Friend request accepted! Friendship ID: {friendshipId}"); + }); - private void OnFriendRequestAccepted(Guid friendshipId) - { - Console.WriteLine($"[Event] Запрос в друзья принят. ID дружбы: {friendshipId}"); - } + // Событие: запрос отклонён + _connection.On("FriendRequestRejected", friendshipId => + { + Console.WriteLine($"❌ Friend request rejected! Friendship ID: {friendshipId}"); + }); - private void OnFriendRequestRejected(Guid friendshipId) - { - Console.WriteLine($"[Event] Запрос в друзья отклонён. ID дружбы: {friendshipId}"); - } - - public async Task StartAsync() - { await _connection.StartAsync(); - Console.WriteLine("Подключено к FriendsHub"); + Console.WriteLine("✅ Connected to FriendsHub"); } - public async Task StopAsync() + public async Task SendFriendRequestAsync(Guid targetUserId) { - await _connection.StopAsync(); - Console.WriteLine("Отключено от FriendsHub"); + var result = await _connection.InvokeAsync>("SendRequest", targetUserId); + Console.WriteLine($"📤 Friend request sent. Status: {result.Status}"); } - public async Task SendRequest(Guid targetUserId) + public async Task AcceptFriendRequestAsync(Guid friendshipId) { - try - { - await _connection.InvokeAsync("SendRequest", targetUserId); - Console.WriteLine($"Запрос на добавление в друзья отправлен пользователю {targetUserId}"); - } - catch (HubException ex) - { - Console.WriteLine($"Ошибка при отправке запроса: {ex.Message}"); - } + var result = await _connection.InvokeAsync>("AcceptRequest", friendshipId); + Console.WriteLine($"👍 Accept result: {result.Status}"); } - public async Task AcceptRequest(Guid friendshipId) + public async Task RejectFriendRequestAsync(Guid friendshipId) { - try - { - await _connection.InvokeAsync("AcceptRequest", friendshipId); - Console.WriteLine($"Запрос в друзья с ID {friendshipId} принят"); - } - catch (HubException ex) - { - Console.WriteLine($"Ошибка при принятии запроса: {ex.Message}"); - } - } - - public async Task RejectRequest(Guid friendshipId) - { - try - { - await _connection.InvokeAsync("RejectRequest", friendshipId); - Console.WriteLine($"Запрос в друзья с ID {friendshipId} отклонён"); - } - catch (HubException ex) - { - Console.WriteLine($"Ошибка при отклонении запроса: {ex.Message}"); - } - } -} - -class Program -{ - static async Task TestMain(string[] args) - { - // Адрес хаба SignalR - string hubUrl = "https://yourserver.com/api/friends"; - - // JWT токен для аутентификации (замени на настоящий) - string jwtToken = "your_jwt_token_here"; - - var client = new FriendsHubClient(hubUrl, jwtToken); - - await client.StartAsync(); - - Console.WriteLine("Команды:"); - Console.WriteLine("send - Отправить запрос в друзья"); - Console.WriteLine("accept - Принять запрос"); - Console.WriteLine("reject - Отклонить запрос"); - Console.WriteLine("exit - Выйти"); - - while (true) - { - Console.Write("> "); - var input = Console.ReadLine(); - if (string.IsNullOrWhiteSpace(input)) - continue; - - var parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); - var command = parts[0].ToLowerInvariant(); - - if (command == "exit") - break; - - if (parts.Length < 2) - { - Console.WriteLine("Ошибка: не хватает аргументов"); - continue; - } - - if (!Guid.TryParse(parts[1], out var id)) - { - Console.WriteLine("Ошибка: некорректный GUID"); - continue; - } - - switch (command) - { - case "send": - await client.SendRequest(id); - break; - case "accept": - await client.AcceptRequest(id); - break; - case "reject": - await client.RejectRequest(id); - break; - default: - Console.WriteLine("Неизвестная команда"); - break; - } - } - - await client.StopAsync(); + var result = await _connection.InvokeAsync>("RejectRequest", friendshipId); + Console.WriteLine($"👎 Reject result: {result.Status}"); } } diff --git a/Govor.Console/Govor.Console.csproj b/Govor.Console/Govor.Console.csproj index 6f99954..0f9919f 100644 --- a/Govor.Console/Govor.Console.csproj +++ b/Govor.Console/Govor.Console.csproj @@ -10,6 +10,7 @@ + diff --git a/Govor.Console/Program.cs b/Govor.Console/Program.cs index 2247a05..9ac0bbf 100644 --- a/Govor.Console/Program.cs +++ b/Govor.Console/Program.cs @@ -1,382 +1,53 @@ -using Microsoft.AspNetCore.SignalR.Client; -using System; -using System.Collections.Generic; -using System.IdentityModel.Tokens.Jwt; -using System.Linq; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Threading.Tasks; -using Govor.ConsoleClient.Commands; -using Govor.Contracts.Requests.SignalR; -using Govor.Contracts.Responses.SignalR; -using Govor.Core.Models; -using Govor.Core.Models.Messages; +using Govor.ConsoleClient; -/*==================================== - *Личные сообщения| Егор - *==================================== - * [15:59] Вы добавили (Егор) в друзья - * - * [16:30] Егор >> Привет! - * [16:31] Ты >> Че как? - * - *------------------------------------ - * >> "Пойдем завтра гулять?!" - */ - -namespace Govor.ConsoleClient +internal class Program { - class Program + private const string HubBaseUrl = "https://govor-team-govor-88b3.twc1.net/hubs"; + private static string jwtToken = "eyJhbGciOiJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGRzaWctbW9yZSNobWFjLXNoYTI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI4NDRjNzkyMi1hNjdlLTRlMzctYWI0MC0wNThlZDE5NzM5NjMiLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3JvbGUiOiJBZG1pbiIsImV4cCI6MTc1MjQ4MzE0MH0.ECXLPwIljdeWgWUJtBiXXQXKMFC8Iw0x7_zdjbsUe1I"; // сюда подставьте валидный JWT + + static async Task Main() { - static string baseUrl = "https://govor-team-govor-88b3.twc1.net"; - //static string baseUrl = "https://localhost:7155"; - static string? AuthToken = null; - static HttpClientService HttpService = new(baseUrl); - private static FriendsClient? _friendsClient; // Renamed to avoid conflict with BaseCommand - static HubConnection? _hubConnection; - static Dictionary> ChatHistory = new(); - static string? CurrentChatUser = null; - static Guid CurrentChatUserId = Guid.Empty; + Console.Write("🔑 JWT Token: "); + jwtToken = Console.ReadLine(); - private static Dictionary _commands = new(); + var client = new FriendsHubClient(HubBaseUrl, jwtToken); + await client.ConnectAsync(); - static async Task Main() + while (true) { - FriendsHubClient client = new FriendsHubClient($"{baseUrl}/hubs/friends", "eyJhbGciOiJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGRzaWctbW9yZSNobWFjLXNoYTI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI4NDRjNzkyMi1hNjdlLTRlMzctYWI0MC0wNThlZDE5NzM5NjMiLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3JvbGUiOiJBZG1pbiIsImV4cCI6MTc1MjMzMjE2NX0.mGSjSCvguEXkdHqHcbZ3eUH0S8c82kz3XP89potEh1k"); - - await client.StartAsync(); - await client.AcceptRequest(Guid.Parse("3ba77c0c-7522-47be-8e77-ea47bd6c6e69")); - - return; - InitializeCommands(); - BaseCommand.InitializeServices( - _friendsClient, // Initially null, will be set by Login/Register commands - HttpService, - () => AuthToken, - (token) => AuthToken = token, - InitializeHubConnection, - () => _hubConnection - ); + Console.WriteLine("\n1. Send friend request"); + Console.WriteLine("2. Accept request"); + Console.WriteLine("3. Reject request"); + Console.Write("➡ Choose option: "); + var option = Console.ReadLine(); - LoginWindow(); - - while (true) + switch (option) { - Console.Write(CurrentChatUser == null ? "/> " : ">> "); - var input = Console.ReadLine(); - - if (string.IsNullOrWhiteSpace(input)) continue; - - if (input.StartsWith("/")) - await HandleCommandAsync(input); - else if (CurrentChatUser != null) - await SendMessageAsync(input); - else - Console.WriteLine("[Система] Введите команду, например /help или /friends"); + case "1": + Console.Write("Target UserId: "); + if (Guid.TryParse(Console.ReadLine(), out var targetId)) + { + await client.SendFriendRequestAsync(targetId); + } + break; + case "2": + Console.Write("FriendshipId to accept: "); + if (Guid.TryParse(Console.ReadLine(), out var acceptId)) + { + await client.AcceptFriendRequestAsync(acceptId); + } + break; + case "3": + Console.Write("FriendshipId to reject: "); + if (Guid.TryParse(Console.ReadLine(), out var rejectId)) + { + await client.RejectFriendRequestAsync(rejectId); + } + break; + default: + Console.WriteLine("❗ Unknown option"); + break; } } - - static void InitializeCommands() - { - _commands = new Dictionary - { - { "/login", new LoginCommand() }, - { "/reg", new RegisterCommand() }, - { "/search", new SearchUserCommand() }, - { "/friends", new ListFriendsCommand() }, - { "/friend", new SendFriendRequestCommand() }, // Alias for SendFriendRequest - { "/sendrequest", new SendFriendRequestCommand() }, - { "/accept", new AcceptFriendRequestCommand() }, - { "/reject", new RejectFriendRequestCommand() }, - { "/incoming", new ListIncomingRequestsCommand() }, - { "/outgoing", new ListOutgoingRequestsCommand() }, - { "/block", new BlockUserCommand() }, - { "/unblock", new UnblockUserCommand() }, - - { "/adminlistallfs", new AdminListAllFriendshipsCommand() }, - { "/adminlistuserfs", new AdminListUserFriendshipsCommand() }, - { "/adminremovefs", new AdminRemoveFriendshipCommand() }, - // Help command is special as it needs the list of commands - // It will be added after other commands are initialized - }; - _commands.Add("/help", new HelpCommand(_commands)); - _commands.Add("-h", new HelpCommand(_commands)); // Alias for help - - // Add other general commands not refactored yet if any, or create classes for them - // For example, /exit, /chat, /back will be handled separately for now or made into commands - } - - // Public static method to allow commands (like Login/Register) to update the FriendsClient - public static void UpdateFriendsClient(FriendsClient newFriendsClient) - { - _friendsClient = newFriendsClient; - // Re-initialize services in BaseCommand with the new FriendsClient - BaseCommand.InitializeServices( - _friendsClient, - HttpService, - () => AuthToken, - (token) => AuthToken = token, - InitializeHubConnection, - () => _hubConnection - ); - } - - - static async Task HandleCommandAsync(string input) - { - var parts = input.Split(new[] { ' ' }, 2, StringSplitOptions.RemoveEmptyEntries); - var commandKey = parts[0].ToLower(); - var argument = parts.Length > 1 ? parts[1] : null; - - if (_commands.TryGetValue(commandKey, out var commandInstance)) - { - try - { - await commandInstance.ExecuteAsync(argument); - } - catch (Exception ex) - { - Console.WriteLine($"[Критическая ошибка выполнения команды {commandKey}]: {ex.Message}"); - // Log detailed exception: Console.WriteLine(ex.ToString()); - } - } - else - { - // Handle non-refactored commands for now - switch (commandKey) - { - case "/exit": - Console.WriteLine("[Система] Выход..."); - if (_hubConnection != null) - { - await _hubConnection.StopAsync(); - await _hubConnection.DisposeAsync(); - } - Environment.Exit(0); - break; - case "/chat": - if (string.IsNullOrEmpty(argument)) - { - Console.WriteLine("[Ошибка] Укажите имя друга и ID через пробел (например /chat Egor GUID)"); - break; - } - - var chatArgs = argument.Split(' ', 2); - if (chatArgs.Length < 2 || !Guid.TryParse(chatArgs[1], out var chatUserId)) - { - Console.WriteLine("[Ошибка] Неверный формат. Укажите имя друга и ID (например /chat Egor GUID)"); - break; - } - - CurrentChatUser = chatArgs[0]; - CurrentChatUserId = chatUserId; - ShowChat(CurrentChatUser); - break; - - case "/back": - CurrentChatUser = null; - CurrentChatUserId = Guid.Empty; - Console.Clear(); // Optionally clear console when going back - LoginWindow(); // Or some other general view - break; - default: - Console.WriteLine($"[Ошибка] Неизвестная команда: {commandKey}. Введите /help для списка команд."); - break; - } - } - } - - static async Task InitializeHubConnection() - { - if (string.IsNullOrEmpty(AuthToken)) - { - // This check might be redundant if commands calling this ensure login first - Console.WriteLine("[Ошибка] Токен аутентификации отсутствует. Пожалуйста, войдите в систему."); - return; - } - - if (_hubConnection != null && _hubConnection.State != HubConnectionState.Disconnected) - { - Console.WriteLine("[SignalR] Соединение уже установлено или устанавливается."); - return; - } - - _hubConnection = new HubConnectionBuilder() - .WithUrl($"{baseUrl}/hubs/chats", options => - { - options.AccessTokenProvider = () => Task.FromResult(AuthToken); - }) - .Build(); - - _hubConnection.On("ReceiveMessage", (message) => - { - var myId = GetMyUserId(); - var senderName = message.SenderId == myId ? "Ты" : CurrentChatUser ?? message.SenderId.ToString() ?? "Неизвестно"; - var displayMessage = $"[{message.SentAt:HH:mm}] {senderName} >> {message.EncryptedContent}"; - - // Determine chat key based on sender/recipient, ensuring consistency - string chatKey = message.SenderId == myId ? message.RecipientId.ToString() : message.SenderId.ToString(); - string activeChatDisplayUser = CurrentChatUser; // User whose chat window is open - - // If the message belongs to the currently active chat window - if (CurrentChatUser != null && (message.SenderId == CurrentChatUserId || (message.RecipientId == CurrentChatUserId && message.SenderId == myId))) - { - if (!ChatHistory.ContainsKey(activeChatDisplayUser)) - { - ChatHistory[activeChatDisplayUser] = new List(); - } - ChatHistory[activeChatDisplayUser].Add(displayMessage); - - // Smartly update console without disrupting user input too much - int originalCursorTop = Console.CursorTop; - int originalCursorLeft = Console.CursorLeft; - Console.MoveBufferArea(0, originalCursorTop, Console.BufferWidth, 1, 0, originalCursorTop +1); // scroll current line down - Console.SetCursorPosition(0, originalCursorTop); - Console.WriteLine($" * {displayMessage}"); - Console.SetCursorPosition(originalCursorLeft, originalCursorTop +1); // +1 because writeline added a line - Console.Write(">> "); // Re-display prompt part - } - else // Notification for message not in the current chat - { - Console.WriteLine($"\n[Новое сообщение от {message.SenderId.ToString()}]: {message.EncryptedContent}"); - Console.Write(CurrentChatUser == null ? "/> " : ">> "); // Re-display prompt - } - }); - - _hubConnection.On("MessageSent", (message) => - { - // Optional: Handle confirmation that message was sent successfully by the server - // For console, maybe a subtle notification or log if verbose mode is on. - // Example: Console.WriteLine($"[Система] Сообщение для {message.RecipientUsername} доставлено на сервер."); - }); - - _hubConnection.On("FriendRequestReceived", (message) => // Assuming message is a simple string notification - { - Console.WriteLine($"\n[Система] Новый запрос в друзья: {message}"); - Console.Write(CurrentChatUser == null ? "/> " : ">> "); - }); - - _hubConnection.On("FriendRequestAccepted", (message) => - { - Console.WriteLine($"\n[Система] Ваш запрос в друзья принят: {message}"); - Console.Write(CurrentChatUser == null ? "/> " : ">> "); - }); - - _hubConnection.On("FriendRemoved", (message) => - { - Console.WriteLine($"\n[Система] Пользователь удалил вас из друзей или вы удалили его: {message}"); - Console.Write(CurrentChatUser == null ? "/> " : ">> "); - }); - - _hubConnection.On("UserBlocked", (message) => - { - Console.WriteLine($"\n[Система] Пользователь заблокирован: {message}"); - Console.Write(CurrentChatUser == null ? "/> " : ">> "); - }); - _hubConnection.On("UserUnblocked", (message) => - { - Console.WriteLine($"\n[Система] Пользователь разблокирован: {message}"); - Console.Write(CurrentChatUser == null ? "/> " : ">> "); - }); - - - try - { - await _hubConnection.StartAsync(); - Console.WriteLine("[SignalR] Соединение установлено."); - } - catch (Exception ex) - { - Console.WriteLine($"[SignalR Ошибка] {ex.Message}"); - } - } - - static Guid GetMyUserId() - { - if (AuthToken == null) return Guid.Empty; - var handler = new JwtSecurityTokenHandler(); - if (!handler.CanReadToken(AuthToken)) return Guid.Empty; - var jwtToken = handler.ReadJwtToken(AuthToken); - var userIdClaim = jwtToken.Claims.FirstOrDefault(claim => claim.Type == "userId" || claim.Type == System.Security.Claims.ClaimTypes.NameIdentifier); - return userIdClaim != null && Guid.TryParse(userIdClaim.Value, out var userId) ? userId : Guid.Empty; - } - - - static void ShowChat(string user) - { - Console.Clear(); - Console.WriteLine($"/*===================================="); - Console.WriteLine($" * Личные сообщения | {user} ({CurrentChatUserId})"); - Console.WriteLine($" * (/back для выхода из чата)"); - Console.WriteLine($" *===================================="); - - if (!ChatHistory.ContainsKey(user)) - { - ChatHistory[user] = new List(); - // Potentially load chat history here via an API call if desired - // ChatHistory[user].Add($"[Система] Начало чата с {user}. Введите сообщение или /back."); - } - else - { - foreach (var msg in ChatHistory[user]) - Console.WriteLine(" * " + msg); - } - Console.WriteLine(" *------------------------------------"); - } - - static async Task SendMessageAsync(string messageContent) - { - if (string.IsNullOrWhiteSpace(messageContent) || _hubConnection == null || CurrentChatUserId == Guid.Empty) return; - - var myUserId = GetMyUserId(); - if (myUserId == Guid.Empty) - { - Console.WriteLine("[Ошибка] Не удалось определить ID пользователя. Попробуйте войти снова."); - return; - } - - var messageRequest = new MessageRequest - { - RecipientId = CurrentChatUserId, - EncryptedContent = messageContent, - RecipientType = RecipientType.User - }; - - try - { - await _hubConnection.InvokeAsync("Send", messageRequest); - - var displayMessage = $"[{DateTime.Now:HH:mm}] Ты >> {messageContent}"; - if (!ChatHistory.ContainsKey(CurrentChatUser!)) - { - ChatHistory[CurrentChatUser!] = new List(); - } - ChatHistory[CurrentChatUser!].Add(displayMessage); - // No Console.WriteLine here, message will be echoed by "ReceiveMessage" if server sends it to self, - // or we rely on user seeing their own typed message. - // The ReceiveMessage handler should ideally handle self-messages for consistent display. - } - catch (Exception ex) - { - Console.WriteLine($"[Ошибка отправки] {ex.Message}"); - } - } - - static void LoginWindow() - { - Console.WriteLine($"/*===================================="); - Console.WriteLine($" * Добро пожаловать в Govor!"); - Console.WriteLine($" *===================================="); - Console.WriteLine(" * /reg - создать аккаунт "); - Console.WriteLine(" * /login - войти"); - Console.WriteLine(" * /help - список команд"); - } } -} - -// LoginResponse class was here, can be removed if not used elsewhere or moved to a Contracts project. -// Assuming it's implicitly handled by HttpClientService or was specific to the old Program.cs structure. -// public class LoginResponse -// { -// public string token { get; set; } -// } \ No newline at end of file +} \ No newline at end of file diff --git a/Govor.Contracts/Govor.Contracts.csproj b/Govor.Contracts/Govor.Contracts.csproj index 20e0a8f..1618bf5 100644 --- a/Govor.Contracts/Govor.Contracts.csproj +++ b/Govor.Contracts/Govor.Contracts.csproj @@ -10,4 +10,10 @@ + + + ..\..\..\..\..\Program Files\dotnet\shared\Microsoft.AspNetCore.App\8.0.17\Microsoft.AspNetCore.Http.Features.dll + + + diff --git a/Govor.Contracts/Requests/MediaUploadRequest.cs b/Govor.Contracts/Requests/MediaUploadRequest.cs index 4408dc1..32a8a99 100644 --- a/Govor.Contracts/Requests/MediaUploadRequest.cs +++ b/Govor.Contracts/Requests/MediaUploadRequest.cs @@ -1,10 +1,13 @@ +using System.ComponentModel.DataAnnotations; using Govor.Core.Models.Messages; +using Microsoft.AspNetCore.Http; namespace Govor.Contracts.Requests; public class MediaUploadRequest { - public byte[] Data { get; set; } + [Required] + public IFormFile Data { get; set; } public string FileName { get; set; } public string EncryptedKey { get; set; } = string.Empty; public MediaType Type { get; set; } diff --git a/Govor.Contracts/Responses/SignalR/MessageEditResponse.cs b/Govor.Contracts/Responses/SignalR/MessageEditResponse.cs index a82ecca..f03c638 100644 --- a/Govor.Contracts/Responses/SignalR/MessageEditResponse.cs +++ b/Govor.Contracts/Responses/SignalR/MessageEditResponse.cs @@ -1,6 +1,13 @@ +using Govor.Core.Models.Messages; + namespace Govor.Contracts.Responses.SignalR; public class MessageEditResponse { - + public Guid MessageId { get; set; } + public Guid EditorId { get; set; } + public Guid RecipientId { get; init; } + public RecipientType RecipientType{get; init; } + public string NewEncryptedContent { get; set; } = string.Empty; + public DateTime EditedAt { get; set; } } \ No newline at end of file diff --git a/Govor.Core/Models/MediaFile.cs b/Govor.Core/Models/MediaFile.cs index 6023416..50d8e59 100644 --- a/Govor.Core/Models/MediaFile.cs +++ b/Govor.Core/Models/MediaFile.cs @@ -5,6 +5,7 @@ namespace Govor.Core.Models; public class MediaFile { public Guid Id { get; set; } + public Guid UploaderId { get; set; } public string Url { get; set; } public MediaType MediaType { get; set; } public string MineType { get; set; } diff --git a/Govor.Core/Models/Users/PrivacyUserSettings.cs b/Govor.Core/Models/Users/PrivacyUserSettings.cs index 1fece99..c0a19a8 100644 --- a/Govor.Core/Models/Users/PrivacyUserSettings.cs +++ b/Govor.Core/Models/Users/PrivacyUserSettings.cs @@ -25,7 +25,7 @@ public enum WhoCan { None = 0, OnlyFriends = 1, - EveryoneCanSend = 2, + Everyone = 2, } public enum DeletingMessagesVia diff --git a/Govor.Data/Migrations/20250713101943_AddUploaderIdToMediaFile.Designer.cs b/Govor.Data/Migrations/20250713101943_AddUploaderIdToMediaFile.Designer.cs new file mode 100644 index 0000000..69fad3a --- /dev/null +++ b/Govor.Data/Migrations/20250713101943_AddUploaderIdToMediaFile.Designer.cs @@ -0,0 +1,596 @@ +// +using System; +using Govor.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Govor.Data.Migrations +{ + [DbContext(typeof(GovorDbContext))] + [Migration("20250713101943_AddUploaderIdToMediaFile")] + partial class AddUploaderIdToMediaFile + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("Govor.Core.Models.ChatGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ImageId") + .HasColumnType("char(36)"); + + b.Property("IsChannel") + .HasColumnType("tinyint(1)"); + + b.Property("IsPrivate") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("Id"); + + b.ToTable("ChatGroups"); + }); + + modelBuilder.Entity("Govor.Core.Models.Friendship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AddresseeId") + .HasColumnType("char(36)"); + + b.Property("RequesterId") + .HasColumnType("char(36)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AddresseeId"); + + b.HasIndex("RequesterId"); + + b.ToTable("Friendships"); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupAdmins", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupAdmins"); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("EndDate") + .HasColumnType("datetime(6)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("InvitationCode") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("MaxParticipants") + .HasColumnType("int"); + + b.Property("UserMakerId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("UserMakerId"); + + b.ToTable("GroupInvitations"); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupMembership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("InvitationId") + .IsRequired() + .HasColumnType("char(36)"); + + b.Property("IsBanned") + .HasColumnType("tinyint(1)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("InvitationId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("Govor.Core.Models.Invitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("DateCreated") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("EndDate") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("IsAdmin") + .HasColumnType("tinyint(1)"); + + b.Property("MaxParticipants") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Invitations"); + }); + + modelBuilder.Entity("Govor.Core.Models.MediaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("DateCreated") + .HasColumnType("datetime(6)"); + + b.Property("MediaType") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("MineType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("UploaderId") + .HasColumnType("char(36)"); + + b.Property("Url") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("MediaFiles"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.MediaAttachments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("MediaFileId") + .HasColumnType("char(36)"); + + b.Property("MessageId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("MediaFileId"); + + b.HasIndex("MessageId"); + + b.ToTable("MediaAttachments"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ChatGroupId") + .HasColumnType("char(36)"); + + b.Property("EditedAt") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedContent") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsEdited") + .HasColumnType("tinyint(1)"); + + b.Property("PrivateChatId") + .HasColumnType("char(36)"); + + b.Property("RecipientId") + .HasColumnType("char(36)"); + + b.Property("RecipientType") + .HasColumnType("int"); + + b.Property("ReplyToMessageId") + .HasColumnType("char(36)"); + + b.Property("SenderId") + .HasColumnType("char(36)"); + + b.Property("SentAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ChatGroupId"); + + b.HasIndex("PrivateChatId"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.MessageReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("MessageId") + .HasColumnType("char(36)"); + + b.Property("ReactedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReactionCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("MessageId", "UserId") + .IsUnique(); + + b.ToTable("MessageReactions"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.MessageView", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("MessageId") + .HasColumnType("char(36)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("ViewedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("MessageId", "UserId") + .IsUnique(); + + b.ToTable("MessageViews"); + }); + + modelBuilder.Entity("Govor.Core.Models.PrivateChat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("UserAId") + .HasColumnType("char(36)"); + + b.Property("UserBId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.ToTable("PrivateChats"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.Admin", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("UserId"); + + b.ToTable("Admins"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedOn") + .HasColumnType("date"); + + b.Property("Description") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IconId") + .HasColumnType("char(36)"); + + b.Property("InviteId") + .HasColumnType("char(36)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Username") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("WasOnline") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("InviteId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Govor.Core.Models.Friendship", b => + { + b.HasOne("Govor.Core.Models.Users.User", "Addressee") + .WithMany("ReceivedFriendRequests") + .HasForeignKey("AddresseeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Govor.Core.Models.Users.User", "Requester") + .WithMany("SentFriendRequests") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Addressee"); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupAdmins", b => + { + b.HasOne("Govor.Core.Models.ChatGroup", null) + .WithMany("Admins") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupInvitation", b => + { + b.HasOne("Govor.Core.Models.ChatGroup", null) + .WithMany("InviteCodes") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Govor.Core.Models.Users.User", "UserMaker") + .WithMany() + .HasForeignKey("UserMakerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("UserMaker"); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupMembership", b => + { + b.HasOne("Govor.Core.Models.ChatGroup", null) + .WithMany("Members") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Govor.Core.Models.GroupInvitation", null) + .WithMany() + .HasForeignKey("InvitationId") + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.MediaAttachments", b => + { + b.HasOne("Govor.Core.Models.MediaFile", "MediaFile") + .WithMany() + .HasForeignKey("MediaFileId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Govor.Core.Models.Messages.Message", "Message") + .WithMany("MediaAttachments") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MediaFile"); + + b.Navigation("Message"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.Message", b => + { + b.HasOne("Govor.Core.Models.ChatGroup", null) + .WithMany("Messages") + .HasForeignKey("ChatGroupId"); + + b.HasOne("Govor.Core.Models.PrivateChat", null) + .WithMany("Messages") + .HasForeignKey("PrivateChatId"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.MessageReaction", b => + { + b.HasOne("Govor.Core.Models.Messages.Message", "Message") + .WithMany("Reactions") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Govor.Core.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Message"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.MessageView", b => + { + b.HasOne("Govor.Core.Models.Messages.Message", null) + .WithMany("MessageViews") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.Admin", b => + { + b.HasOne("Govor.Core.Models.Users.User", "User") + .WithOne() + .HasForeignKey("Govor.Core.Models.Users.Admin", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.User", b => + { + b.HasOne("Govor.Core.Models.Invitation", "Invite") + .WithMany("Users") + .HasForeignKey("InviteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Invite"); + }); + + modelBuilder.Entity("Govor.Core.Models.ChatGroup", b => + { + b.Navigation("Admins"); + + b.Navigation("InviteCodes"); + + b.Navigation("Members"); + + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Govor.Core.Models.Invitation", b => + { + b.Navigation("Users"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.Message", b => + { + b.Navigation("MediaAttachments"); + + b.Navigation("MessageViews"); + + b.Navigation("Reactions"); + }); + + modelBuilder.Entity("Govor.Core.Models.PrivateChat", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.User", b => + { + b.Navigation("ReceivedFriendRequests"); + + b.Navigation("SentFriendRequests"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Govor.Data/Migrations/20250713101943_AddUploaderIdToMediaFile.cs b/Govor.Data/Migrations/20250713101943_AddUploaderIdToMediaFile.cs new file mode 100644 index 0000000..db02898 --- /dev/null +++ b/Govor.Data/Migrations/20250713101943_AddUploaderIdToMediaFile.cs @@ -0,0 +1,31 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Govor.Data.Migrations +{ + /// + public partial class AddUploaderIdToMediaFile : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "UploaderId", + table: "MediaFiles", + type: "char(36)", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), + collation: "ascii_general_ci"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "UploaderId", + table: "MediaFiles"); + } + } +} diff --git a/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs b/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs index 6937a5e..33d36a5 100644 --- a/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs +++ b/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs @@ -215,6 +215,9 @@ namespace Govor.Data.Migrations .HasMaxLength(128) .HasColumnType("varchar(128)"); + b.Property("UploaderId") + .HasColumnType("char(36)"); + b.Property("Url") .IsRequired() .HasColumnType("longtext"); From ab643c16a4d57c4515d89d5e028b9f1ae3281475 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Fri, 18 Jul 2025 20:16:36 +0700 Subject: [PATCH 048/101] Implement user session management and JWT refresh tokens Added user session models, interfaces, repository, and service for managing user sessions and refresh tokens. Refactored authentication flow to return user objects and open sessions with device info, supporting refresh token generation and validation. Updated JWT configuration to separate access and refresh options, and refactored related tests and API contracts. Improved media upload handling and error logging. Migrated dependency references and DI registrations accordingly. --- .github/workflows/qodana.yml | 22 + .../Controllers/AuthControllerTests.cs | 30 +- .../AdminStuff/InviteUserController.cs | 2 +- .../Controllers/AdminStuff/UsersController.cs | 1 - Govor.API/Controllers/AuthController.cs | 75 +- Govor.API/Controllers/ChatLoadController.cs | 2 +- Govor.API/Controllers/MediaController.cs | 32 +- .../ConfigurationProgramExtensions.cs | 10 +- Govor.API/Govor.API.csproj | 4 + Govor.API/Program.cs | 2 +- Govor.API/appsettings.json | 11 +- .../AdminsStuff/InvitationGeneratorTests.cs | 2 +- .../Authentication/AuthServiceTests.cs | 3 +- .../Authentication/JwtServiceTests.cs | 90 ++- .../UserSessions/UserSessionOpenerTests.cs | 127 ++++ .../AdminsStuff/InvitationGenerator.cs | 4 +- .../Interfaces/Authentication/IAuthService.cs | 5 +- .../Authentication/IInvitesService.cs | 2 +- .../Interfaces/Authentication/IJwtService.cs | 5 +- .../Interfaces/IInvitionGenerator.cs | 2 +- .../Interfaces/Medias/IMediaService.cs | 2 +- .../UserSession/IUserSessionOpener.cs | 10 + .../UserSession/IUserSessionReader.cs | 6 + .../UserSession/IUserSessionRevoker.cs | 8 + .../Services/Authentication/AuthService.cs | 33 +- .../{JwtOption.cs => JwtAccessOption.cs} | 4 +- .../Authentication/JwtRefreshOption.cs | 6 + .../Services/Authentication/JwtService.cs | 59 +- Govor.Application/Services/InvitesService.cs | 2 +- .../Services/Messages/MediaService.cs | 5 +- .../UserSessions/UserSessionOpener.cs | 68 ++ Govor.Contracts/Govor.Contracts.csproj | 5 +- Govor.Contracts/Requests/LoginRequest.cs | 1 + .../Requests/MediaUploadRequest.cs | 8 +- .../Requests/RegistrationRequest.cs | 1 + Govor.Core/Models/UserSession.cs | 25 + .../IUserSessionsExist.cs | 10 + .../IUserSessionsReader.cs | 13 + .../IUserSessionsRepository.cs | 8 + .../IUserSessionsWriter.cs | 11 + .../UserSessionsRepositoryTests.cs | 274 ++++++++ .../UserSessionConfiguration.cs | 25 + Govor.Data/GovorDbContext.cs | 3 +- .../20250718130008_usersessions.Designer.cs | 639 ++++++++++++++++++ .../Migrations/20250718130008_usersessions.cs | 53 ++ .../Migrations/GovorDbContextModelSnapshot.cs | 43 ++ .../Repositories/UserSessionsRepository.cs | 162 +++++ qodana.yaml | 29 + 48 files changed, 1860 insertions(+), 84 deletions(-) create mode 100644 .github/workflows/qodana.yml create mode 100644 Govor.Application.Tests/Services/UserSessions/UserSessionOpenerTests.cs create mode 100644 Govor.Application/Interfaces/UserSession/IUserSessionOpener.cs create mode 100644 Govor.Application/Interfaces/UserSession/IUserSessionReader.cs create mode 100644 Govor.Application/Interfaces/UserSession/IUserSessionRevoker.cs rename Govor.Application/Services/Authentication/{JwtOption.cs => JwtAccessOption.cs} (58%) create mode 100644 Govor.Application/Services/Authentication/JwtRefreshOption.cs create mode 100644 Govor.Application/Services/UserSessions/UserSessionOpener.cs create mode 100644 Govor.Core/Models/UserSession.cs create mode 100644 Govor.Core/Repositories/UserSessionsRepository/IUserSessionsExist.cs create mode 100644 Govor.Core/Repositories/UserSessionsRepository/IUserSessionsReader.cs create mode 100644 Govor.Core/Repositories/UserSessionsRepository/IUserSessionsRepository.cs create mode 100644 Govor.Core/Repositories/UserSessionsRepository/IUserSessionsWriter.cs create mode 100644 Govor.Data.Tests/Repositories/UserSessionsRepositoryTests.cs create mode 100644 Govor.Data/Configurations/UserSessionConfiguration.cs create mode 100644 Govor.Data/Migrations/20250718130008_usersessions.Designer.cs create mode 100644 Govor.Data/Migrations/20250718130008_usersessions.cs create mode 100644 Govor.Data/Repositories/UserSessionsRepository.cs create mode 100644 qodana.yaml diff --git a/.github/workflows/qodana.yml b/.github/workflows/qodana.yml new file mode 100644 index 0000000..ba604d9 --- /dev/null +++ b/.github/workflows/qodana.yml @@ -0,0 +1,22 @@ +name: Qodana Analysis + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + qodana: + name: Run Qodana Code Analysis + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Run Qodana + uses: JetBrains/qodana-action@v2024.1 + with: + # Указываем путь к qodana.yaml (если он не в корне) + args: --ide QDNET --project-dir . diff --git a/Govor.API.Tests/IntegrationTests/Controllers/AuthControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/AuthControllerTests.cs index 1866a5f..994f1ec 100644 --- a/Govor.API.Tests/IntegrationTests/Controllers/AuthControllerTests.cs +++ b/Govor.API.Tests/IntegrationTests/Controllers/AuthControllerTests.cs @@ -1,11 +1,12 @@ using AutoFixture; using Govor.API.Controllers; -using Govor.API.Services.Authentication.Interfaces; using Govor.Application.Exceptions.AuthService; using Govor.Application.Exceptions.InvitesService; using Govor.Application.Interfaces.Authentication; +using Govor.Application.Interfaces.UserSession; using Govor.Contracts.Requests; using Govor.Core.Models; +using Govor.Core.Models.Users; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Moq; @@ -19,6 +20,7 @@ public class AuthControllerTests private Mock _accountServiceMock; private Mock _invitesServiceMock; private Mock> _loggerMock; + private Mock _userSessionOpenerMock; private AuthController _controller; [SetUp] @@ -31,10 +33,12 @@ public class AuthControllerTests _accountServiceMock = new Mock(); _invitesServiceMock = new Mock(); _loggerMock = new Mock>(); - + _userSessionOpenerMock = new Mock(); + _controller = new AuthController( _accountServiceMock.Object, _invitesServiceMock.Object, + _userSessionOpenerMock.Object, _loggerMock.Object ); } @@ -47,9 +51,18 @@ public class AuthControllerTests var request = _fixture.Create(); var invitation = _fixture.Create(); var token = _fixture.Create(); - + + var user = _fixture.Build() + .With(x => x.Username).Create(); + _invitesServiceMock.Setup(s => s.ValidateAsync(request.InviteLink)).ReturnsAsync(invitation); - _accountServiceMock.Setup(s => s.RegistrationAsync(request.Name, request.Password, invitation)).ReturnsAsync(token); + + + _accountServiceMock.Setup(l => l.RegistrationAsync(request.Name, request.Password, invitation)) + .ReturnsAsync(user); + + _userSessionOpenerMock.Setup(f => f.OpenSessionAsync(user, request.DeviceInfo)) + .ReturnsAsync(token); // Act var result = await _controller.Register(request); @@ -137,7 +150,14 @@ public class AuthControllerTests // Arrange var loginRequest = _fixture.Create(); var token = _fixture.Create(); - _accountServiceMock.Setup(l => l.LoginAsync(loginRequest.Name, loginRequest.Password)).ReturnsAsync(token); + + var user = _fixture.Build() + .With(x => x.Username).Create(); + + _accountServiceMock.Setup(l => l.LoginAsync(loginRequest.Name, loginRequest.Password)).ReturnsAsync(user); + + _userSessionOpenerMock.Setup(f => f.OpenSessionAsync(user, loginRequest.DeviceInfo)) + .ReturnsAsync(token); // Act var result = await _controller.Login(loginRequest); diff --git a/Govor.API/Controllers/AdminStuff/InviteUserController.cs b/Govor.API/Controllers/AdminStuff/InviteUserController.cs index d5d28c1..00121b8 100644 --- a/Govor.API/Controllers/AdminStuff/InviteUserController.cs +++ b/Govor.API/Controllers/AdminStuff/InviteUserController.cs @@ -1,4 +1,4 @@ -using Govor.API.Services.AdminsStuff.Interfaces; +using Govor.Application.Interfaces; using Govor.Contracts.DTOs; using Govor.Contracts.Requests; using Govor.Core.Repositories.Invaites; diff --git a/Govor.API/Controllers/AdminStuff/UsersController.cs b/Govor.API/Controllers/AdminStuff/UsersController.cs index fa75d8e..dd180e8 100644 --- a/Govor.API/Controllers/AdminStuff/UsersController.cs +++ b/Govor.API/Controllers/AdminStuff/UsersController.cs @@ -1,4 +1,3 @@ -using Govor.API.Services.AdminsStuff.Interfaces; using Govor.Application.Interfaces; using Govor.Contracts.Responses.Admins; using Govor.Core.Models.Users; diff --git a/Govor.API/Controllers/AuthController.cs b/Govor.API/Controllers/AuthController.cs index 20d8287..b6e6a7e 100644 --- a/Govor.API/Controllers/AuthController.cs +++ b/Govor.API/Controllers/AuthController.cs @@ -1,7 +1,7 @@ -using Govor.API.Services.Authentication.Interfaces; using Govor.Application.Exceptions.AuthService; using Govor.Application.Exceptions.InvitesService; using Govor.Application.Interfaces.Authentication; +using Govor.Application.Interfaces.UserSession; using Govor.Contracts.Requests; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -13,33 +13,38 @@ namespace Govor.API.Controllers; [Route("api/[controller]")] public class AuthController : Controller { + private IUserSessionOpener _userSession; private IInvitesService _invitesService; private IAccountService _accountService; private ILogger _logger; - public AuthController(IAccountService accountService, IInvitesService invitesService, ILogger logger) + public AuthController(IAccountService accountService, IInvitesService invitesService,IUserSessionOpener userSessionOpener, ILogger logger) { + _userSession = userSessionOpener; _accountService = accountService; _invitesService = invitesService; _logger = logger; } + [RequireHttps] [HttpPost("register")]// api/auth/register - //[RequireHttps] public async Task Register([FromBody] RegistrationRequest registrationRequest) { try { if (!ModelState.IsValid) - { return BadRequest(ModelState); - } var invite = await _invitesService.ValidateAsync(registrationRequest.InviteLink); - var token = await _accountService.RegistrationAsync(registrationRequest.Name, registrationRequest.Password, + var user = await _accountService.RegistrationAsync(registrationRequest.Name, registrationRequest.Password, invite); - _logger.LogInformation($"Register request for {registrationRequest.Name} processed successfully"); + + _logger.LogInformation($"Register request for {user.Username} with id {user.Id} processed successfully"); + + var token = await _userSession.OpenSessionAsync(user, registrationRequest.DeviceInfo); + + _logger.LogInformation($"Session for user {user.Username} with id {user.Id} has been opened"); return Ok(new { token }); } catch (UserAlreadyExistException ex) @@ -64,35 +69,73 @@ public class AuthController : Controller } } + [RequireHttps] [HttpPost("login")]// api/auth/login - //[RequireHttps] - public async Task Login([FromBody] LoginRequest userRequest) + public async Task Login([FromBody] LoginRequest loginRequest) { try { if (!ModelState.IsValid) - { return BadRequest(ModelState); - } - var token = await _accountService.LoginAsync(userRequest.Name, userRequest.Password); - _logger.LogInformation($"Login request for {userRequest.Name} processed successfully"); + var user = await _accountService.LoginAsync(loginRequest.Name, loginRequest.Password); + _logger.LogInformation($"Login request for {user.Username} with id {user.Id} processed successfully"); + + var token = await _userSession.OpenSessionAsync(user, loginRequest.DeviceInfo); + + _logger.LogInformation($"Session for user {user.Username} with id {user.Id} has been opened"); + return Ok(new { token }); } catch (UserNotRegisteredException ex) { - _logger.LogWarning(ex, "Login failed for user {Name}", userRequest.Name); + _logger.LogWarning(ex, "Login failed for user {Name}", loginRequest.Name); return BadRequest("Login failed: user does not exist."); } catch (LoginUserException ex) { - _logger.LogWarning(ex, "Login failed for user {Name}", userRequest.Name); + _logger.LogWarning(ex, "Login failed for user {Name}", loginRequest.Name); return BadRequest("Login failed: username or password is incorrect."); } catch (Exception ex) { - _logger.LogError(ex, "Unexpected error during login for user {Name}", userRequest.Name); + _logger.LogError(ex, "Unexpected error during login for user {Name}", loginRequest.Name); return StatusCode(500, "An unexpected error occurred. Please try again later."); } } + +/* + [RequireHttps] + [HttpPost("refresh")] + public async Task Refresh([FromBody] string refreshToken) + { + try + { + if (!ModelState.IsValid) + return BadRequest(ModelState); + + if (string.IsNullOrEmpty(refreshToken)) + throw new InvalidOperationException("Refresh token cant be empty."); + + var newAccessToken = await _accountService.RefreshTokenAsync(refreshToken); + return Ok(new { accessToken = newAccessToken }); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "Invalid refresh token"); + return BadRequest(ex.Message); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, "Refresh token failed"); + return Unauthorized("Invalid refresh token"); + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + return StatusCode(500, "An unexpected error occurred."); + } + } +*/ + } \ No newline at end of file diff --git a/Govor.API/Controllers/ChatLoadController.cs b/Govor.API/Controllers/ChatLoadController.cs index 9a18d3c..4a1fa6f 100644 --- a/Govor.API/Controllers/ChatLoadController.cs +++ b/Govor.API/Controllers/ChatLoadController.cs @@ -32,7 +32,7 @@ public class ChatLoadController : Controller public async Task GetChatMessages( [FromQuery] Guid chatId, [FromQuery] Guid? startMessageId, - [FromQuery] int pageSize = 20) + [FromQuery] int pageSize) { try { diff --git a/Govor.API/Controllers/MediaController.cs b/Govor.API/Controllers/MediaController.cs index d842430..4768b0f 100644 --- a/Govor.API/Controllers/MediaController.cs +++ b/Govor.API/Controllers/MediaController.cs @@ -29,32 +29,51 @@ public class MediaController : Controller } [HttpPost("upload")] - [RequestSizeLimit(100_000_000)] // ~100MB + [RequestSizeLimit(20_000_000)] // ~20MB public async Task Upload([FromForm] MediaUploadRequest request) { try { + if (request.FromFile.Length > 20_000_000) + return BadRequest("File is too large"); + if (!ModelState.IsValid) return BadRequest(ModelState); // Чтение байт из IFormFile using var memoryStream = new MemoryStream(); - await request.Data.CopyToAsync(memoryStream); + + await request.FromFile.CopyToAsync(memoryStream); + byte[] fileBytes = memoryStream.ToArray(); var media = new Media( _currentUserService.GetCurrentUserId(), DateTime.UtcNow, + Path.GetFileName(request.FromFile.FileName), fileBytes, - request.FileName, request.Type, request.MimeType, request.EncryptedKey ); var result = await _mediaService.UploadMediaAsync(media); + + _logger.LogInformation( + $"Uploaded file: {Path.GetFileName(request.FromFile.FileName)} from user {_currentUserService.GetCurrentUserId()}"); + return Ok(result); } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, ex.Message); + return BadRequest(ex.Message); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, ex.Message); + return Unauthorized(ex.Message); + } catch (Exception ex) { _logger.LogError(ex, "Error uploading media"); @@ -71,7 +90,12 @@ public class MediaController : Controller return BadRequest(ModelState); var media = await _mediaService.GetMediaByIdAsync(id); - return File(media.Data, media.MineType, media.FileName); + return File(media.Data, media.MineType, Path.GetFileName(media.FileName)); + } + catch (KeyNotFoundException ex) + { + _logger.LogWarning(ex, ex.Message); + return NotFound(ex.Message); } catch (Exception ex) { diff --git a/Govor.API/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Extensions/ConfigurationProgramExtensions.cs index 2689d8a..beb91f9 100644 --- a/Govor.API/Extensions/ConfigurationProgramExtensions.cs +++ b/Govor.API/Extensions/ConfigurationProgramExtensions.cs @@ -1,5 +1,3 @@ -using Govor.API.Services.AdminsStuff.Interfaces; -using Govor.API.Services.Authentication.Interfaces; using Govor.Application.Infrastructure.AdminsStuff; using Govor.Application.Infrastructure.Extensions; using Govor.Application.Infrastructure.Validators; @@ -9,10 +7,12 @@ using Govor.Application.Interfaces.Friends; using Govor.Application.Interfaces.Infrastructure.Extensions; using Govor.Application.Interfaces.Medias; using Govor.Application.Interfaces.Messages; +using Govor.Application.Interfaces.UserSession; using Govor.Application.Services; using Govor.Application.Services.Authentication; using Govor.Application.Services.Friends; using Govor.Application.Services.Messages; +using Govor.Application.Services.UserSessions; using Govor.Core.Infrastructure.Extensions; using Govor.Core.Infrastructure.Validators; using Govor.Core.Models; @@ -26,6 +26,7 @@ using Govor.Core.Repositories.MediasAttachments; using Govor.Core.Repositories.Messages; using Govor.Core.Repositories.PrivateChats; using Govor.Core.Repositories.Users; +using Govor.Core.Repositories.UserSessionsRepository; using Govor.Data; using Govor.Data.Repositories; using Microsoft.EntityFrameworkCore; @@ -67,6 +68,9 @@ public static class ConfigurationProgramExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + + // UserSession + services.AddScoped(); // Auto Mapper services.AddAutoMapper(typeof(MappingProfile)); @@ -82,7 +86,7 @@ public static class ConfigurationProgramExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); - + services.AddScoped(); // other } diff --git a/Govor.API/Govor.API.csproj b/Govor.API/Govor.API.csproj index c7f5397..0f12b70 100644 --- a/Govor.API/Govor.API.csproj +++ b/Govor.API/Govor.API.csproj @@ -25,5 +25,9 @@ + + + + diff --git a/Govor.API/Program.cs b/Govor.API/Program.cs index 1b4b4cb..b911e88 100644 --- a/Govor.API/Program.cs +++ b/Govor.API/Program.cs @@ -26,7 +26,7 @@ builder.Services.AddCors(options => }); }); -builder.Services.Configure(configuration.GetSection(nameof(JwtOption))); +builder.Services.Configure(configuration.GetSection(nameof(JwtAccessOption))); // Add services builder.Services.AddSignalRConf();// signalR diff --git a/Govor.API/appsettings.json b/Govor.API/appsettings.json index 132a5a0..c1f2289 100644 --- a/Govor.API/appsettings.json +++ b/Govor.API/appsettings.json @@ -9,9 +9,12 @@ "GovorDbContext": "Server=147.45.255.215;Port=3306;Database=artemy_DB;User=artemy;Password=LoxHuy))228Goy;" }, "UseMySql": true, - "AllowedHosts": "*", - "JwtOption": { - "SecretKeу": "MY VERY SECRET KEY asdasdpafjhasofafpajsfj", - "Hours": "24" + "AllowedHosts": "govor-team-govor-88b3.twc1.net", + "JwtAccessOption": { + "SecretKey": "Q89eY7zP7C4+TqLmHF4kw9xkF1E8Ru4Zpg+up9wFt9g=", + "Minutes": 25 + }, + "JwtRefreshOption": { + "RefreshTokenLifetimeDays": 30 } } diff --git a/Govor.Application.Tests/Infrastructure/AdminsStuff/InvitationGeneratorTests.cs b/Govor.Application.Tests/Infrastructure/AdminsStuff/InvitationGeneratorTests.cs index 4ad9d52..698b6ed 100644 --- a/Govor.Application.Tests/Infrastructure/AdminsStuff/InvitationGeneratorTests.cs +++ b/Govor.Application.Tests/Infrastructure/AdminsStuff/InvitationGeneratorTests.cs @@ -1,6 +1,6 @@ using AutoFixture; -using Govor.API.Services.AdminsStuff.Interfaces; using Govor.Application.Infrastructure.AdminsStuff; +using Govor.Application.Interfaces; using Govor.Core.Models; using Govor.Core.Repositories.Invaites; using Moq; diff --git a/Govor.Application.Tests/Services/Authentication/AuthServiceTests.cs b/Govor.Application.Tests/Services/Authentication/AuthServiceTests.cs index 5964ad8..4a85b28 100644 --- a/Govor.Application.Tests/Services/Authentication/AuthServiceTests.cs +++ b/Govor.Application.Tests/Services/Authentication/AuthServiceTests.cs @@ -2,10 +2,8 @@ using AutoFixture; using Govor.Core.Infrastructure.Extensions; using Govor.Core.Models; using Govor.Core.Repositories.Users; -using Govor.API.Services.Authentication.Interfaces; using Govor.Application.Exceptions.AuthService; using Govor.Application.Interfaces.Authentication; -using Govor.Application.Services; using Govor.Application.Services.Authentication; using Govor.Core.Models.Users; using Govor.Core.Repositories.Admins; @@ -52,6 +50,7 @@ public class AuthServiceTests ); } + // Tests for Register action [Test] public void Given_ExistUser_When_Register_Should_Throw_UserAlreadyExistsException() { diff --git a/Govor.Application.Tests/Services/Authentication/JwtServiceTests.cs b/Govor.Application.Tests/Services/Authentication/JwtServiceTests.cs index d7a2889..cc0b8fe 100644 --- a/Govor.Application.Tests/Services/Authentication/JwtServiceTests.cs +++ b/Govor.Application.Tests/Services/Authentication/JwtServiceTests.cs @@ -1,10 +1,12 @@ using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; using AutoFixture; -using Govor.API.Services.Authentication.Interfaces; using Govor.Application.Interfaces.Authentication; using Govor.Application.Services.Authentication; using Govor.Core.Models.Users; using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; using Moq; namespace Govor.Application.Tests.Services.Authentication; @@ -13,11 +15,13 @@ namespace Govor.Application.Tests.Services.Authentication; public class JwtServiceTests { private Fixture _fixture; - private Mock> _jwtOptionsMock; + private Mock> _jwtOptionsMock; + private Mock> _jwtRefreshOptionsMock; private Mock _invitesServiceMock; private IJwtService _jwtService; - private JwtOption _testJwtOptions; + private JwtAccessOption _testJwtAccessOptions; + private JwtRefreshOption _testJwtRefreshOptions; [SetUp] public void SetUp() @@ -26,18 +30,29 @@ public class JwtServiceTests _fixture.Behaviors.OfType().ToList().ForEach(b => _fixture.Behaviors.Remove(b)); _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); - _testJwtOptions = new JwtOption + _testJwtAccessOptions = new JwtAccessOption { - SecretKeу = "THIS IS A TEST SECRET KEY THAT IS LONG ENOUGH", // Ensure key size is sufficient for HMACSHA256 - Hours = 1 + SecretKeу = "THIS_IS_A_TEST_SECRET_KEY_THAT_IS_LONG_ENOUGH_1234", // Ensure key size is sufficient for HMACSHA256 + Minutes = 5 }; - _jwtOptionsMock = new Mock>(); - _jwtOptionsMock.Setup(o => o.Value).Returns(_testJwtOptions); - + _testJwtRefreshOptions = new JwtRefreshOption() + { + RefreshTokenLifetimeDays = 30 + }; + + _jwtOptionsMock = new Mock>(); + _jwtOptionsMock.Setup(o => o.Value).Returns(_testJwtAccessOptions); + + _jwtRefreshOptionsMock = new Mock>(); + _jwtRefreshOptionsMock.Setup(o => o.Value).Returns(_testJwtRefreshOptions); + _invitesServiceMock = new Mock(); - _jwtService = new JwtService(_jwtOptionsMock.Object, _invitesServiceMock.Object); + _jwtService = new JwtService( + _jwtOptionsMock.Object, + _jwtRefreshOptionsMock.Object, + _invitesServiceMock.Object); } [Test] @@ -48,7 +63,7 @@ public class JwtServiceTests var expectedRole = "User"; _invitesServiceMock.Setup(s => s.GetRoleAsync(user)).Returns(Task.FromResult(expectedRole)); // Act - var tokenString = await _jwtService.GenerateJwtTokenAsync(user); + var tokenString = await _jwtService.GenerateAccessTokenAsync(user); // Assert Assert.That(tokenString, Is.Not.Null.And.Not.Empty); @@ -57,4 +72,57 @@ public class JwtServiceTests var handler = new JwtSecurityTokenHandler(); Assert.DoesNotThrow(() => handler.ReadJwtToken(tokenString)); } + + [Test] + public async Task GenerateRefreshTokenAsync_ReturnsValidRefreshToken() + { + // Arrange + var user = new User { Id = Guid.NewGuid() }; + + // Act + var token = await _jwtService.GenerateRefreshTokenAsync(user); + + // Assert + Assert.That(token, Is.Not.Null); + + var handler = new JwtSecurityTokenHandler(); + var jwt = handler.ReadJwtToken(token); + + Assert.That(user.Id.ToString(), Is.EqualTo(jwt.Claims.First(c => c.Type == "userId").Value)); + Assert.That("refresh",Is.EqualTo(jwt.Claims.First(c => c.Type == "tokenType").Value)); + } + + [Test] + public async Task GetPrincipalFromExpiredToken_ReturnsValidClaimsPrincipal() + { + // Arrange + var userId = Guid.NewGuid(); + var handler = new JwtSecurityTokenHandler(); + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_testJwtAccessOptions.SecretKeу)); + + var now = DateTime.UtcNow; + + var token = handler.CreateToken(new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(new[] + { + new Claim("userId", userId.ToString()) + }), + NotBefore = now.AddSeconds(-10), + IssuedAt = now.AddSeconds(-10), + Expires = now.AddSeconds(-5), + SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256) + }); + + var expiredToken = handler.WriteToken(token); + + // Act + var principal = _jwtService.GetPrincipalFromExpiredToken(expiredToken); + + // Assert + Assert.That(principal, Is.Not.Null); + var claim = principal.FindFirst("userId"); + Assert.That(claim, Is.Not.Null); + Assert.That(userId.ToString(), Is.EqualTo(claim.Value)); + } } \ No newline at end of file diff --git a/Govor.Application.Tests/Services/UserSessions/UserSessionOpenerTests.cs b/Govor.Application.Tests/Services/UserSessions/UserSessionOpenerTests.cs new file mode 100644 index 0000000..a50ff21 --- /dev/null +++ b/Govor.Application.Tests/Services/UserSessions/UserSessionOpenerTests.cs @@ -0,0 +1,127 @@ +using Govor.Application.Interfaces.Authentication; +using Govor.Application.Services.Authentication; +using Govor.Application.Services.UserSessions; +using Govor.Core.Models; +using Govor.Core.Models.Users; +using Govor.Core.Repositories.UserSessionsRepository; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; + +namespace Govor.Application.Tests.Services.UserSessions; + +[TestFixture] +public class UserSessionOpenerTests +{ + private Mock _repositoryMock; + private Mock _jwtServiceMock; + private Mock> _loggerMock; + private IOptions _options; + private UserSessionOpener _service; + private User _user; + private const string DeviceInfo = "Chrome on Windows"; + private const string GeneratedToken = "new-refresh-token"; + + [SetUp] + public void Setup() + { + _repositoryMock = new Mock(); + _jwtServiceMock = new Mock(); + _loggerMock = new Mock>(); + _options = Options.Create(new JwtRefreshOption { RefreshTokenLifetimeDays = 30 }); + + _service = new UserSessionOpener( + _repositoryMock.Object, + _jwtServiceMock.Object, + _options, + _loggerMock.Object + ); + + _user = new User + { + Id = Guid.NewGuid(), + Username = "test", + PasswordHash = "hashed", + IconId = Guid.NewGuid(), + CreatedOn = DateOnly.FromDateTime(DateTime.UtcNow), + WasOnline = DateTime.UtcNow, + InviteId = Guid.NewGuid() + }; + + _jwtServiceMock + .Setup(j => j.GenerateRefreshTokenAsync(_user)) + .ReturnsAsync(GeneratedToken); + } + + [Test] + public async Task OpenSessionAsync_ShouldReturnExistingToken_IfSessionValid() + { + // Arrange + var session = new Core.Models.UserSession + { + UserId = _user.Id, + DeviceInfo = DeviceInfo, + RefreshToken = "valid-token", + CreatedAt = DateTime.UtcNow.AddDays(-10), + ExpiresAt = DateTime.UtcNow.AddDays(10), + IsRevoked = false + }; + + _repositoryMock + .Setup(r => r.GetByUserIdAsync(_user.Id)) + .ReturnsAsync(new List { session }); + + // Act + var result = await _service.OpenSessionAsync(_user, DeviceInfo); + + // Asser + Assert.That(result, Is.EqualTo("valid-token")); + _repositoryMock.Verify(r => r.UpdateAsync(It.IsAny()), Times.Never); + } + + [Test] + public async Task OpenSessionAsync_ShouldUpdateSession_IfExpiredOrRevoked() + { + // Arrange + var session = new Core.Models.UserSession + { + UserId = _user.Id, + DeviceInfo = DeviceInfo, + RefreshToken = "old-token", + CreatedAt = DateTime.UtcNow.AddDays(-40), + ExpiresAt = DateTime.UtcNow.AddDays(-1), + IsRevoked = false + }; + + _repositoryMock + .Setup(r => r.GetByUserIdAsync(_user.Id)) + .ReturnsAsync(new List { session }); + + // Act + var result = await _service.OpenSessionAsync(_user, DeviceInfo); + + // Assert + Assert.That(result, Is.EqualTo(GeneratedToken)); + _repositoryMock.Verify(r => r.UpdateAsync(It.Is(s => s.RefreshToken == GeneratedToken)), Times.Once); + } + + [Test] + public async Task OpenSessionAsync_ShouldCreateNewSession_IfNoneExists() + { + // Arrange + _repositoryMock + .Setup(r => r.GetByUserIdAsync(_user.Id)) + .ReturnsAsync(new List()); + + // Act + var result = await _service.OpenSessionAsync(_user, DeviceInfo); + + // Assert + Assert.That(result, Is.EqualTo(GeneratedToken)); + _repositoryMock.Verify(r => r.AddAsync(It.Is(s => + s.UserId == _user.Id && + s.DeviceInfo == DeviceInfo && + s.RefreshToken == GeneratedToken + )), Times.Once); + } +} diff --git a/Govor.Application/Infrastructure/AdminsStuff/InvitationGenerator.cs b/Govor.Application/Infrastructure/AdminsStuff/InvitationGenerator.cs index d448c49..f2000b7 100644 --- a/Govor.Application/Infrastructure/AdminsStuff/InvitationGenerator.cs +++ b/Govor.Application/Infrastructure/AdminsStuff/InvitationGenerator.cs @@ -1,4 +1,4 @@ -using Govor.API.Services.AdminsStuff.Interfaces; +using Govor.Application.Interfaces; using Govor.Core.Models; using Govor.Core.Repositories.Invaites; @@ -19,7 +19,7 @@ public class InvitationGenerator(IInvitesRepository repository) : IInvitationGen IsAdmin = isAdmin }; - await repository.AddAsync(newInvitation); + await repository.AddAsync(newInvitation); return newInvitation.Code; } diff --git a/Govor.Application/Interfaces/Authentication/IAuthService.cs b/Govor.Application/Interfaces/Authentication/IAuthService.cs index 1f54b69..010e82c 100644 --- a/Govor.Application/Interfaces/Authentication/IAuthService.cs +++ b/Govor.Application/Interfaces/Authentication/IAuthService.cs @@ -1,9 +1,10 @@ using Govor.Core.Models; +using Govor.Core.Models.Users; namespace Govor.Application.Interfaces.Authentication; public interface IAccountService { - public Task RegistrationAsync(string name, string password, Invitation invitation); - public Task LoginAsync(string name, string password); + public Task RegistrationAsync(string name, string password, Invitation invitation); + public Task LoginAsync(string name, string password); } \ No newline at end of file diff --git a/Govor.Application/Interfaces/Authentication/IInvitesService.cs b/Govor.Application/Interfaces/Authentication/IInvitesService.cs index 566f145..86e24f8 100644 --- a/Govor.Application/Interfaces/Authentication/IInvitesService.cs +++ b/Govor.Application/Interfaces/Authentication/IInvitesService.cs @@ -1,7 +1,7 @@ using Govor.Core.Models; using Govor.Core.Models.Users; -namespace Govor.API.Services.Authentication.Interfaces; +namespace Govor.Application.Interfaces.Authentication; public interface IInvitesService { diff --git a/Govor.Application/Interfaces/Authentication/IJwtService.cs b/Govor.Application/Interfaces/Authentication/IJwtService.cs index 6904693..18c7477 100644 --- a/Govor.Application/Interfaces/Authentication/IJwtService.cs +++ b/Govor.Application/Interfaces/Authentication/IJwtService.cs @@ -1,8 +1,11 @@ +using System.Security.Claims; using Govor.Core.Models.Users; namespace Govor.Application.Interfaces.Authentication; public interface IJwtService { - Task GenerateJwtTokenAsync(User user); + Task GenerateAccessTokenAsync(User user); + Task GenerateRefreshTokenAsync(User user); + ClaimsPrincipal GetPrincipalFromExpiredToken(string token); } \ No newline at end of file diff --git a/Govor.Application/Interfaces/IInvitionGenerator.cs b/Govor.Application/Interfaces/IInvitionGenerator.cs index 0c2f932..05d80c2 100644 --- a/Govor.Application/Interfaces/IInvitionGenerator.cs +++ b/Govor.Application/Interfaces/IInvitionGenerator.cs @@ -1,4 +1,4 @@ -namespace Govor.API.Services.AdminsStuff.Interfaces; +namespace Govor.Application.Interfaces; public interface IInvitationGenerator { diff --git a/Govor.Application/Interfaces/Medias/IMediaService.cs b/Govor.Application/Interfaces/Medias/IMediaService.cs index 48a8232..b8a9735 100644 --- a/Govor.Application/Interfaces/Medias/IMediaService.cs +++ b/Govor.Application/Interfaces/Medias/IMediaService.cs @@ -12,8 +12,8 @@ public interface IMediaService public record Media(Guid UploaderId, DateTime UploadedOn, - byte[] Data, string FileName, + byte[] Data, MediaType Type, string MineType, string EncryptedKey); diff --git a/Govor.Application/Interfaces/UserSession/IUserSessionOpener.cs b/Govor.Application/Interfaces/UserSession/IUserSessionOpener.cs new file mode 100644 index 0000000..641084d --- /dev/null +++ b/Govor.Application/Interfaces/UserSession/IUserSessionOpener.cs @@ -0,0 +1,10 @@ +using Govor.Core.Models.Users; + +namespace Govor.Application.Interfaces.UserSession; + + +public interface IUserSessionOpener +{ + Task OpenSessionAsync(User user, string deviceInfo); +} + diff --git a/Govor.Application/Interfaces/UserSession/IUserSessionReader.cs b/Govor.Application/Interfaces/UserSession/IUserSessionReader.cs new file mode 100644 index 0000000..3824747 --- /dev/null +++ b/Govor.Application/Interfaces/UserSession/IUserSessionReader.cs @@ -0,0 +1,6 @@ +namespace Govor.Application.Interfaces.UserSession; + +public interface IUserSessionReader +{ + Task> GetAllSessionsAsync(Guid userId); +} diff --git a/Govor.Application/Interfaces/UserSession/IUserSessionRevoker.cs b/Govor.Application/Interfaces/UserSession/IUserSessionRevoker.cs new file mode 100644 index 0000000..6106db5 --- /dev/null +++ b/Govor.Application/Interfaces/UserSession/IUserSessionRevoker.cs @@ -0,0 +1,8 @@ +namespace Govor.Application.Interfaces.UserSession; + + +public interface IUserSessionRevoker +{ + Task CloseSessionByRefreshTokenAsync(string refreshToken); + Task CloseAllSessionsAsync(Guid userId); +} diff --git a/Govor.Application/Services/Authentication/AuthService.cs b/Govor.Application/Services/Authentication/AuthService.cs index dbfcf01..1c4b2bf 100644 --- a/Govor.Application/Services/Authentication/AuthService.cs +++ b/Govor.Application/Services/Authentication/AuthService.cs @@ -30,7 +30,7 @@ public class AuthService : IAccountService _usernameValidator = usernameValidator; } - public async Task RegistrationAsync(string name, string password, Invitation invitation) + public async Task RegistrationAsync(string name, string password, Invitation invitation) { _usernameValidator.Validate(name); @@ -55,11 +55,11 @@ public class AuthService : IAccountService await SetRole(user, invitation); - return await _jwtService.GenerateJwtTokenAsync(user); + return user; } - public async Task LoginAsync(string name, string password) + public async Task LoginAsync(string name, string password) { if (await _usersRepository.ExistsUsernameAsync(name) == false) throw new UserNotRegisteredException(name); @@ -69,9 +69,34 @@ public class AuthService : IAccountService if (_passwordHasher.Verify(password, user.PasswordHash) == false) throw new LoginUserException(); - return await _jwtService.GenerateJwtTokenAsync(user); + return user; } + + /* + public async Task RefreshTokenAsync(string refreshToken) + { + try + { + var principal = _jwtService.GetPrincipalFromExpiredToken(refreshToken); + + var userId = Guid.Parse(principal?.FindFirst("userId")?.Value ?? string.Empty); + var storedTokens = await _userSessionsRepository.GetUserTokensAsync(userId); + + if (!storedTokens.Contains(refreshToken)) + throw new UnauthorizedAccessException("Invalid refresh token"); + + var user = await _usersRepository.FindByIdAsync(userId); + var newAccessToken = await _jwtService.GenerateAccessTokenAsync(user); + return newAccessToken; + } + catch (SecurityTokenException ex) + { + throw new InvalidOperationException("Invalid refresh token", ex); + } + } + */ + private async Task SetRole(User user, Invitation invitation) { if(invitation.IsAdmin) diff --git a/Govor.Application/Services/Authentication/JwtOption.cs b/Govor.Application/Services/Authentication/JwtAccessOption.cs similarity index 58% rename from Govor.Application/Services/Authentication/JwtOption.cs rename to Govor.Application/Services/Authentication/JwtAccessOption.cs index a4ebef1..6308a92 100644 --- a/Govor.Application/Services/Authentication/JwtOption.cs +++ b/Govor.Application/Services/Authentication/JwtAccessOption.cs @@ -1,6 +1,6 @@ namespace Govor.Application.Services.Authentication; -public class JwtOption +public class JwtAccessOption { public string SecretKeу {get; set;} - public int Hours { get; set; } + public int Minutes { get; set; } } \ No newline at end of file diff --git a/Govor.Application/Services/Authentication/JwtRefreshOption.cs b/Govor.Application/Services/Authentication/JwtRefreshOption.cs new file mode 100644 index 0000000..3e88c1a --- /dev/null +++ b/Govor.Application/Services/Authentication/JwtRefreshOption.cs @@ -0,0 +1,6 @@ +namespace Govor.Application.Services.Authentication; + +public class JwtRefreshOption +{ + public int RefreshTokenLifetimeDays { get; set; } +} \ No newline at end of file diff --git a/Govor.Application/Services/Authentication/JwtService.cs b/Govor.Application/Services/Authentication/JwtService.cs index 9f5e8e4..7110fbd 100644 --- a/Govor.Application/Services/Authentication/JwtService.cs +++ b/Govor.Application/Services/Authentication/JwtService.cs @@ -1,7 +1,6 @@ using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; -using Govor.API.Services.Authentication.Interfaces; using Govor.Application.Interfaces.Authentication; using Govor.Core.Models.Users; using Microsoft.Extensions.Options; @@ -11,16 +10,18 @@ namespace Govor.Application.Services.Authentication; public class JwtService : IJwtService { - private JwtOption _jwtOption; + private JwtAccessOption _jwtAccessOption; + private JwtRefreshOption _refreshOptions; private IInvitesService _invitesService; - public JwtService(IOptions options, IInvitesService invitesService) + public JwtService(IOptions options, IOptions refreshOptions, IInvitesService invitesService) { - _jwtOption = options.Value; + _refreshOptions = refreshOptions.Value; + _jwtAccessOption = options.Value; _invitesService = invitesService; } - public async Task GenerateJwtTokenAsync(User user) + public async Task GenerateAccessTokenAsync(User user) { var claims = new[] { @@ -29,13 +30,55 @@ public class JwtService : IJwtService }; var singing = new SigningCredentials( - new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOption.SecretKeу)), + new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtAccessOption.SecretKeу)), SecurityAlgorithms.HmacSha256Signature); - var token = new JwtSecurityToken(signingCredentials: singing, - expires: DateTime.UtcNow.AddHours(_jwtOption.Hours), + var token = new JwtSecurityToken( + expires: DateTime.UtcNow.AddMinutes(_jwtAccessOption.Minutes), + signingCredentials: singing, claims: claims); return new JwtSecurityTokenHandler().WriteToken(token); } + + public async Task GenerateRefreshTokenAsync(User user) + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtAccessOption.SecretKeу)); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new[] + { + new Claim("userId", user.Id.ToString()), + new Claim("tokenType", "refresh") + }; + + var token = new JwtSecurityToken( + expires: DateTime.UtcNow.AddDays(_refreshOptions.RefreshTokenLifetimeDays), + signingCredentials: creds, + claims: claims + ); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + public ClaimsPrincipal GetPrincipalFromExpiredToken(string token) + { + var tokenValidationParameters = new TokenValidationParameters + { + ValidateAudience = false, + ValidateIssuer = false, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtAccessOption.SecretKeу)), + ValidateLifetime = false // << important + }; + + var handler = new JwtSecurityTokenHandler(); + var principal = handler.ValidateToken(token, tokenValidationParameters, out var securityToken); + + if (securityToken is not JwtSecurityToken jwtToken || jwtToken.Header.Alg != SecurityAlgorithms.HmacSha256) + throw new SecurityTokenException("Invalid token"); + + return principal; + } + } \ No newline at end of file diff --git a/Govor.Application/Services/InvitesService.cs b/Govor.Application/Services/InvitesService.cs index 0a5a240..a9ea40e 100644 --- a/Govor.Application/Services/InvitesService.cs +++ b/Govor.Application/Services/InvitesService.cs @@ -1,5 +1,5 @@ -using Govor.API.Services.Authentication.Interfaces; using Govor.Application.Exceptions.InvitesService; +using Govor.Application.Interfaces.Authentication; using Govor.Core.Models; using Govor.Core.Models.Users; using Govor.Core.Repositories.Invaites; diff --git a/Govor.Application/Services/Messages/MediaService.cs b/Govor.Application/Services/Messages/MediaService.cs index 21bba28..547e393 100644 --- a/Govor.Application/Services/Messages/MediaService.cs +++ b/Govor.Application/Services/Messages/MediaService.cs @@ -67,7 +67,7 @@ public class MediaService : IMediaService var mediaFile = await _dbContext.MediaFiles .AsNoTracking() .FirstOrDefaultAsync(x => x.Id == mediaId) - ?? throw new KeyNotFoundException("No media found"); + ?? throw new KeyNotFoundException($"No media found by given id {mediaId}"); // Загрузить бинарные данные из хранилища Stream dataStream = await _storageService.LoadAsync(mediaFile.Url); @@ -83,8 +83,8 @@ public class MediaService : IMediaService return new Media( mediaFile.UploaderId, mediaFile.DateCreated, + mediaFile.MediaType.ToString(), contentBytes, - string.Empty, mediaFile.MediaType, mediaFile.MineType, string.Empty @@ -96,5 +96,4 @@ public class MediaService : IMediaService throw; } } - } \ No newline at end of file diff --git a/Govor.Application/Services/UserSessions/UserSessionOpener.cs b/Govor.Application/Services/UserSessions/UserSessionOpener.cs new file mode 100644 index 0000000..e0519f3 --- /dev/null +++ b/Govor.Application/Services/UserSessions/UserSessionOpener.cs @@ -0,0 +1,68 @@ +using Govor.Application.Interfaces.Authentication; +using Govor.Application.Interfaces.UserSession; +using Govor.Application.Services.Authentication; +using Govor.Core.Models.Users; +using Govor.Core.Repositories.UserSessionsRepository; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Govor.Application.Services.UserSessions; + +public class UserSessionOpener : IUserSessionOpener +{ + private readonly IUserSessionsRepository _repository; + private readonly IJwtService _jwtService; + private readonly ILogger _logger; + private readonly JwtRefreshOption _options; + + public UserSessionOpener(IUserSessionsRepository repository, IJwtService jwtService, IOptions options, ILogger logger) + { + _jwtService = jwtService; + _repository = repository; + _logger = logger; + _options = options.Value; + } + public async Task OpenSessionAsync(User user, string deviceInfo) + { + _logger.LogInformation($"Opening session for user {user.Id} on device '{deviceInfo}'"); + + var sessions = await _repository.GetByUserIdAsync(user.Id); + var session = sessions.FirstOrDefault(s => s.DeviceInfo == deviceInfo); + + var newRefreshToken = await _jwtService.GenerateRefreshTokenAsync(user); + var newExpiresAt = DateTime.UtcNow.AddDays(_options.RefreshTokenLifetimeDays); + + if (session is not null) + { + if (session.IsRevoked || session.ExpiresAt <= DateTime.UtcNow) + { + // Update Session + session.RefreshToken = newRefreshToken; + session.ExpiresAt = newExpiresAt; + session.CreatedAt = DateTime.UtcNow; + session.IsRevoked = false; + + await _repository.UpdateAsync(session); + _logger.LogInformation($"Updated expired/revoked session for user {user.Id} on device '{deviceInfo}'"); + } + + return session.RefreshToken; + } + + // New Session + var newSession = new Core.Models.UserSession + { + UserId = user.Id, + DeviceInfo = deviceInfo, + RefreshToken = newRefreshToken, + CreatedAt = DateTime.UtcNow, + ExpiresAt = newExpiresAt, + IsRevoked = false + }; + + await _repository.AddAsync(newSession); + _logger.LogInformation($"Created new session for user {user.Id} on device '{deviceInfo}'"); + + return newRefreshToken; + } +} \ No newline at end of file diff --git a/Govor.Contracts/Govor.Contracts.csproj b/Govor.Contracts/Govor.Contracts.csproj index 1618bf5..6427a81 100644 --- a/Govor.Contracts/Govor.Contracts.csproj +++ b/Govor.Contracts/Govor.Contracts.csproj @@ -11,9 +11,6 @@ - - ..\..\..\..\..\Program Files\dotnet\shared\Microsoft.AspNetCore.App\8.0.17\Microsoft.AspNetCore.Http.Features.dll - + - diff --git a/Govor.Contracts/Requests/LoginRequest.cs b/Govor.Contracts/Requests/LoginRequest.cs index f36455e..f7a0b11 100644 --- a/Govor.Contracts/Requests/LoginRequest.cs +++ b/Govor.Contracts/Requests/LoginRequest.cs @@ -13,4 +13,5 @@ public class LoginRequest [Required] [MinLength(8)] public string Password { get; init; } + public string DeviceInfo { get; init; } } \ No newline at end of file diff --git a/Govor.Contracts/Requests/MediaUploadRequest.cs b/Govor.Contracts/Requests/MediaUploadRequest.cs index 32a8a99..4c3a74b 100644 --- a/Govor.Contracts/Requests/MediaUploadRequest.cs +++ b/Govor.Contracts/Requests/MediaUploadRequest.cs @@ -7,9 +7,11 @@ namespace Govor.Contracts.Requests; public class MediaUploadRequest { [Required] - public IFormFile Data { get; set; } - public string FileName { get; set; } - public string EncryptedKey { get; set; } = string.Empty; + public IFormFile FromFile { get; set; } + [Required] public MediaType Type { get; set; } + [Required, MaxLength(255)] public string MimeType { get; set; } = string.Empty; + [Required] + public string EncryptedKey { get; set; } = string.Empty; } \ No newline at end of file diff --git a/Govor.Contracts/Requests/RegistrationRequest.cs b/Govor.Contracts/Requests/RegistrationRequest.cs index 7189bc8..eb130c8 100644 --- a/Govor.Contracts/Requests/RegistrationRequest.cs +++ b/Govor.Contracts/Requests/RegistrationRequest.cs @@ -15,4 +15,5 @@ public record RegistrationRequest public string Password { get; init; } [MinLength(InvitationValidator.MIN_INVITATION_LENGTH)] public string InviteLink { get; init; } + public string DeviceInfo { get; init; } } diff --git a/Govor.Core/Models/UserSession.cs b/Govor.Core/Models/UserSession.cs new file mode 100644 index 0000000..23ce695 --- /dev/null +++ b/Govor.Core/Models/UserSession.cs @@ -0,0 +1,25 @@ +namespace Govor.Core.Models; + +public class UserSession +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid UserId { get; set; } + public string RefreshToken { get; set; } = string.Empty; + public string DeviceInfo { get; set; } = string.Empty; // например, "Chrome on Windows" + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime ExpiresAt { get; set; } + public bool IsRevoked { get; set; } = false; + + public override bool Equals(object? obj) + { + UserSession? userSession = obj as UserSession; + + return Id == userSession.Id && + UserId == userSession.UserId && + RefreshToken == userSession.RefreshToken && + DeviceInfo == userSession.DeviceInfo && + CreatedAt == userSession.CreatedAt && + ExpiresAt == userSession.ExpiresAt && + IsRevoked == userSession.IsRevoked; + } +} \ No newline at end of file diff --git a/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsExist.cs b/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsExist.cs new file mode 100644 index 0000000..38a16fd --- /dev/null +++ b/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsExist.cs @@ -0,0 +1,10 @@ +using Govor.Core.Models; + +namespace Govor.Core.Repositories.UserSessionsRepository; + +public interface IUserSessionsExist +{ + public bool Exist(Guid sessionId); + public bool Exist(string refresh); + public bool Exist(UserSession userSession); +} \ No newline at end of file diff --git a/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsReader.cs b/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsReader.cs new file mode 100644 index 0000000..0c6e51f --- /dev/null +++ b/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsReader.cs @@ -0,0 +1,13 @@ +using Govor.Core.Models; + +namespace Govor.Core.Repositories.UserSessionsRepository; + +public interface IUserSessionsReader +{ + public Task> GetAllAsync(); + public Task GetByIdAsync(Guid sessionId); + public Task> GetByUserIdAsync(Guid userId); + public Task> GetByCreatedAtAsync(DateTime createdAt); + public Task> GetByExpiresAtAsync(DateTime createdAt); + public Task> GetByRevokedAsync(bool isRevoked); +} \ No newline at end of file diff --git a/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsRepository.cs b/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsRepository.cs new file mode 100644 index 0000000..35061b7 --- /dev/null +++ b/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsRepository.cs @@ -0,0 +1,8 @@ +using Govor.Core.Models; + +namespace Govor.Core.Repositories.UserSessionsRepository; + +public interface IUserSessionsRepository : IUserSessionsReader, IUserSessionsWriter, IUserSessionsExist +{ + +} \ No newline at end of file diff --git a/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsWriter.cs b/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsWriter.cs new file mode 100644 index 0000000..aa6eeaa --- /dev/null +++ b/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsWriter.cs @@ -0,0 +1,11 @@ +using Govor.Core.Models; + +namespace Govor.Core.Repositories.UserSessionsRepository; + +public interface IUserSessionsWriter +{ + Task AddAsync(UserSession userSession); + Task UpdateAsync(UserSession userSession); + Task RemoveAsync(Guid sessionId); + Task RemoveByUserIdAsync(Guid userId); +} \ No newline at end of file diff --git a/Govor.Data.Tests/Repositories/UserSessionsRepositoryTests.cs b/Govor.Data.Tests/Repositories/UserSessionsRepositoryTests.cs new file mode 100644 index 0000000..632a8bd --- /dev/null +++ b/Govor.Data.Tests/Repositories/UserSessionsRepositoryTests.cs @@ -0,0 +1,274 @@ +using AutoFixture; +using Govor.Core.Models; +using Govor.Data.Repositories; +using Govor.Data.Repositories.Exceptions; +using Microsoft.EntityFrameworkCore; + +namespace Govor.Data.Tests.Repositories; + +[TestFixture] +public class UserSessionsRepositoryTests +{ + private Fixture _fixture; + private DbContextOptions _options; + private int _testIteration = 0; + + [SetUp] + public void SetUp() + { + _testIteration += 1; + + _fixture = new Fixture(); + + _fixture.Behaviors + .OfType() + .ToList() + .ForEach(b => _fixture.Behaviors.Remove(b)); + + _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); + + _options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: $"DbGovor_{nameof(UserSessionsRepositoryTests)}_{_testIteration}") + .Options; + } + + [Test] + public async Task GetAllSessions_ReturnsAllSessions() + { + // Arrange + var random = new Random(); + var sessions = _fixture.CreateMany(random.Next(2, 10)).ToList(); + + await using var context = new GovorDbContext(_options); + var repository = new UserSessionsRepository(context); + + context.UserSessions.AddRange(sessions); + await context.SaveChangesAsync(); + + // Act + var result = await repository.GetAllAsync(); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.Count, Is.EqualTo(sessions.Count)); + Assert.That(result, Is.EquivalentTo(sessions)); + } + + [Test] + public void Given_EmptySetDb_When_GetAll_Should_Throw_NotFoundException() + { + // Arrange + using var context = new GovorDbContext(_options); + var repository = new UserSessionsRepository(context); + // Act & Assert + Assert.ThrowsAsync(async () => await repository.GetAllAsync()); + } + + [Test] + public async Task Given_ValidId_When_GetByIdAsync_ShouldReturnSession() + { + // Arrange + var random = new Random(); + var sessions = _fixture.CreateMany(random.Next(2, 10)).ToList(); + + var id = sessions.First().Id; + + await using var context = new GovorDbContext(_options); + var repository = new UserSessionsRepository(context); + + context.UserSessions.AddRange(sessions); + await context.SaveChangesAsync(); + // Act + var result = await repository.GetByIdAsync(id); + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.Id, Is.EqualTo(id)); + Assert.That(result, Is.EqualTo(sessions.First())); + } + + [Test] + public void Given_InvalidId_When_GetByIdAsync_Should_Throw_NotFoundByKeyException() + { + // Arrange + using var context = new GovorDbContext(_options); + var repository = new UserSessionsRepository(context); + // Act & Assert + Assert.ThrowsAsync>(async () => await repository.GetByIdAsync(_fixture.Create())); + } + + [Test] + public async Task Given_ValidCreatedAt_When_GetByCreatedAtAsync_ShouldReturnSession() + { + // Arrange + var random = new Random(); + var sessions = _fixture.CreateMany(random.Next(2, 10)).ToList(); + + var created = sessions.First().CreatedAt; + + await using var context = new GovorDbContext(_options); + var repository = new UserSessionsRepository(context); + + context.UserSessions.AddRange(sessions); + await context.SaveChangesAsync(); + + // Act + var list = await repository.GetByCreatedAtAsync(created); + + + // Assert + var result = list.First(); + Assert.That(result, Is.Not.Null); + Assert.That(result.CreatedAt, Is.EqualTo(created)); + Assert.That(result, Is.EqualTo(sessions.First())); + } + + [Test] + public void Given_ValidCreatedAt_When_GetByCreatedAtAsync_Should_Throw_NotFoundByKeyException() + { + // Arrange + using var context = new GovorDbContext(_options); + var repository = new UserSessionsRepository(context); + // Act & Assert + Assert.ThrowsAsync>(async () => await repository.GetByCreatedAtAsync(_fixture.Create())); + } + + [Test] + public async Task Given_ValidExpiresAt_When_GetByExpiresAtAsync_ShouldReturnSession() + { + // Arrange + var random = new Random(); + var sessions = _fixture.CreateMany(random.Next(2, 10)).ToList(); + + var expiresAt = sessions.First().ExpiresAt; + + await using var context = new GovorDbContext(_options); + var repository = new UserSessionsRepository(context); + + context.UserSessions.AddRange(sessions); + await context.SaveChangesAsync(); + + // Act + var list = await repository.GetByExpiresAtAsync(expiresAt); + + // Assert + var result = list.First(); + Assert.That(result, Is.Not.Null); + Assert.That(result.ExpiresAt, Is.EqualTo(expiresAt)); + Assert.That(result, Is.EqualTo(sessions.First())); + } + + [Test] + public void Given_ValidExpiresAt_When_GetByExpiresAtAsync_Should_Throw_NotFoundByKeyException() + { + // Arrange + using var context = new GovorDbContext(_options); + var repository = new UserSessionsRepository(context); + // Act & Assert + Assert.ThrowsAsync>(async () => await repository.GetByExpiresAtAsync(_fixture.Create())); + } + + [Test] + public async Task Given_isRevoked_When_GetByRevokedAsync_ShouldReturnSession() + { + // Arrange + var random = new Random(); + var sessions = _fixture.Build() + .With(f => f.IsRevoked, true) + .CreateMany(random.Next(2, 10)).ToList(); + + var isRevoke = sessions.First().IsRevoked; + + await using var context = new GovorDbContext(_options); + var repository = new UserSessionsRepository(context); + + context.UserSessions.AddRange(sessions); + await context.SaveChangesAsync(); + + // Act + var result = await repository.GetByRevokedAsync(isRevoke); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result, Is.EquivalentTo(sessions)); + } + + [Test] + public void Given_InvalidRevoked_When_GetByExpiresAtAsync_Should_Throw_NotFoundByKeyException() + { + // Arrange + using var context = new GovorDbContext(_options); + var repository = new UserSessionsRepository(context); + // Act & Assert + Assert.ThrowsAsync>(async () => await repository.GetByRevokedAsync(false)); + } + + [Test] + public async Task Given_ValidUserSessions_When_AddAsync_Then_UserSessionsAdded() + { + // Arrange + var session = _fixture.Create(); + + await using var context = new GovorDbContext(_options); + var repository = new UserSessionsRepository(context); + + // Act + await repository.AddAsync(session); + + // Assert + Assert.That(context.UserSessions.Count, Is.EqualTo(1)); + Assert.That(context.UserSessions.First(), Is.EqualTo(session)); + } + + [Test] + public async Task Given_InvalidUserSessions_When_AddAsync_Should_Throw_AdditionException() + { + // Arrange + await using var context = new GovorDbContext(_options); + var repository = new UserSessionsRepository(context); + + // Act & Assert + Assert.ThrowsAsync(async () => await repository.AddAsync(default)); + } + + [Test] + public async Task Given_ExistUserSessions_When_Exist_Then_ReturnTrue() + { + // Arrange + var session = _fixture.Create(); + + await using var context = new GovorDbContext(_options); + var repository = new UserSessionsRepository(context); + + context.UserSessions.Add(session); + await context.SaveChangesAsync(); + + // Act + var result1 = repository.Exist(session); + var result2 = repository.Exist(session.Id); + var result3 = repository.Exist(session.RefreshToken); + + // Assert + Assert.That(result1, Is.True); + Assert.That(result2, Is.True); + Assert.That(result3, Is.True); + } + + [Test] + public async Task Given_NotExistUserSessions_When_Exist_Then_ReturnFalse() + { + // Arrange + var session = _fixture.Create(); + + await using var context = new GovorDbContext(_options); + var repository = new UserSessionsRepository(context); + + // Act + var result1 = repository.Exist(session); + var result2 = repository.Exist(session.Id); + var result3 = repository.Exist(session.RefreshToken); + // Assert + Assert.That(result1, Is.False); + Assert.That(result2, Is.False); + Assert.That(result3, Is.False); + } +} \ No newline at end of file diff --git a/Govor.Data/Configurations/UserSessionConfiguration.cs b/Govor.Data/Configurations/UserSessionConfiguration.cs new file mode 100644 index 0000000..0c0c297 --- /dev/null +++ b/Govor.Data/Configurations/UserSessionConfiguration.cs @@ -0,0 +1,25 @@ +using Govor.Core.Models; +using Govor.Core.Models.Users; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Govor.Data.Configurations; + +public class UserSessionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(us => us.Id); + + builder.Property(us => us.RefreshToken) + .IsRequired(); + + builder.Property(us => us.DeviceInfo) + .HasMaxLength(200); + + builder.HasOne() + .WithMany() + .HasForeignKey(us => us.UserId) + .OnDelete(DeleteBehavior.Cascade); + } +} \ No newline at end of file diff --git a/Govor.Data/GovorDbContext.cs b/Govor.Data/GovorDbContext.cs index 040314a..47ce2fc 100644 --- a/Govor.Data/GovorDbContext.cs +++ b/Govor.Data/GovorDbContext.cs @@ -1,4 +1,3 @@ -using System.Text.RegularExpressions; using Govor.Core.Models; using Govor.Core.Models.Messages; using Govor.Core.Models.Users; @@ -10,6 +9,7 @@ namespace Govor.Data; public class GovorDbContext(DbContextOptions options) : DbContext(options) { public virtual DbSet Users { get; set; } + public virtual DbSet UserSessions { get; set; } public virtual DbSet Friendships { get; set; } public virtual DbSet PrivateChats { get; set; } public virtual DbSet Admins { get; set; } @@ -29,6 +29,7 @@ public class GovorDbContext(DbContextOptions options) : DbContex protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.ApplyConfiguration(new UserSessionConfiguration()); modelBuilder.ApplyConfiguration(new FriendshipConfiguration()); modelBuilder.ApplyConfiguration(new UserConfiguration()); modelBuilder.ApplyConfiguration(new InvitationConfiguration()); diff --git a/Govor.Data/Migrations/20250718130008_usersessions.Designer.cs b/Govor.Data/Migrations/20250718130008_usersessions.Designer.cs new file mode 100644 index 0000000..e33657b --- /dev/null +++ b/Govor.Data/Migrations/20250718130008_usersessions.Designer.cs @@ -0,0 +1,639 @@ +// +using System; +using Govor.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Govor.Data.Migrations +{ + [DbContext(typeof(GovorDbContext))] + [Migration("20250718130008_usersessions")] + partial class usersessions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("Govor.Core.Models.ChatGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ImageId") + .HasColumnType("char(36)"); + + b.Property("IsChannel") + .HasColumnType("tinyint(1)"); + + b.Property("IsPrivate") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("Id"); + + b.ToTable("ChatGroups"); + }); + + modelBuilder.Entity("Govor.Core.Models.Friendship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AddresseeId") + .HasColumnType("char(36)"); + + b.Property("RequesterId") + .HasColumnType("char(36)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AddresseeId"); + + b.HasIndex("RequesterId"); + + b.ToTable("Friendships"); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupAdmins", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupAdmins"); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("EndDate") + .HasColumnType("datetime(6)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("InvitationCode") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("MaxParticipants") + .HasColumnType("int"); + + b.Property("UserMakerId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("UserMakerId"); + + b.ToTable("GroupInvitations"); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupMembership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("InvitationId") + .IsRequired() + .HasColumnType("char(36)"); + + b.Property("IsBanned") + .HasColumnType("tinyint(1)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("InvitationId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("Govor.Core.Models.Invitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("DateCreated") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("EndDate") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("IsAdmin") + .HasColumnType("tinyint(1)"); + + b.Property("MaxParticipants") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Invitations"); + }); + + modelBuilder.Entity("Govor.Core.Models.MediaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("DateCreated") + .HasColumnType("datetime(6)"); + + b.Property("MediaType") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("MineType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("UploaderId") + .HasColumnType("char(36)"); + + b.Property("Url") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("MediaFiles"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.MediaAttachments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("MediaFileId") + .HasColumnType("char(36)"); + + b.Property("MessageId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("MediaFileId"); + + b.HasIndex("MessageId"); + + b.ToTable("MediaAttachments"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ChatGroupId") + .HasColumnType("char(36)"); + + b.Property("EditedAt") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedContent") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsEdited") + .HasColumnType("tinyint(1)"); + + b.Property("PrivateChatId") + .HasColumnType("char(36)"); + + b.Property("RecipientId") + .HasColumnType("char(36)"); + + b.Property("RecipientType") + .HasColumnType("int"); + + b.Property("ReplyToMessageId") + .HasColumnType("char(36)"); + + b.Property("SenderId") + .HasColumnType("char(36)"); + + b.Property("SentAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ChatGroupId"); + + b.HasIndex("PrivateChatId"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.MessageReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("MessageId") + .HasColumnType("char(36)"); + + b.Property("ReactedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReactionCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("MessageId", "UserId") + .IsUnique(); + + b.ToTable("MessageReactions"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.MessageView", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("MessageId") + .HasColumnType("char(36)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("ViewedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("MessageId", "UserId") + .IsUnique(); + + b.ToTable("MessageViews"); + }); + + modelBuilder.Entity("Govor.Core.Models.PrivateChat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("UserAId") + .HasColumnType("char(36)"); + + b.Property("UserBId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.ToTable("PrivateChats"); + }); + + modelBuilder.Entity("Govor.Core.Models.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DeviceInfo") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime(6)"); + + b.Property("IsRevoked") + .HasColumnType("tinyint(1)"); + + b.Property("RefreshToken") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.Admin", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("UserId"); + + b.ToTable("Admins"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedOn") + .HasColumnType("date"); + + b.Property("Description") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IconId") + .HasColumnType("char(36)"); + + b.Property("InviteId") + .HasColumnType("char(36)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Username") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("WasOnline") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("InviteId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Govor.Core.Models.Friendship", b => + { + b.HasOne("Govor.Core.Models.Users.User", "Addressee") + .WithMany("ReceivedFriendRequests") + .HasForeignKey("AddresseeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Govor.Core.Models.Users.User", "Requester") + .WithMany("SentFriendRequests") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Addressee"); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupAdmins", b => + { + b.HasOne("Govor.Core.Models.ChatGroup", null) + .WithMany("Admins") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupInvitation", b => + { + b.HasOne("Govor.Core.Models.ChatGroup", null) + .WithMany("InviteCodes") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Govor.Core.Models.Users.User", "UserMaker") + .WithMany() + .HasForeignKey("UserMakerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("UserMaker"); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupMembership", b => + { + b.HasOne("Govor.Core.Models.ChatGroup", null) + .WithMany("Members") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Govor.Core.Models.GroupInvitation", null) + .WithMany() + .HasForeignKey("InvitationId") + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.MediaAttachments", b => + { + b.HasOne("Govor.Core.Models.MediaFile", "MediaFile") + .WithMany() + .HasForeignKey("MediaFileId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Govor.Core.Models.Messages.Message", "Message") + .WithMany("MediaAttachments") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MediaFile"); + + b.Navigation("Message"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.Message", b => + { + b.HasOne("Govor.Core.Models.ChatGroup", null) + .WithMany("Messages") + .HasForeignKey("ChatGroupId"); + + b.HasOne("Govor.Core.Models.PrivateChat", null) + .WithMany("Messages") + .HasForeignKey("PrivateChatId"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.MessageReaction", b => + { + b.HasOne("Govor.Core.Models.Messages.Message", "Message") + .WithMany("Reactions") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Govor.Core.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Message"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.MessageView", b => + { + b.HasOne("Govor.Core.Models.Messages.Message", null) + .WithMany("MessageViews") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Govor.Core.Models.UserSession", b => + { + b.HasOne("Govor.Core.Models.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.Admin", b => + { + b.HasOne("Govor.Core.Models.Users.User", "User") + .WithOne() + .HasForeignKey("Govor.Core.Models.Users.Admin", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.User", b => + { + b.HasOne("Govor.Core.Models.Invitation", "Invite") + .WithMany("Users") + .HasForeignKey("InviteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Invite"); + }); + + modelBuilder.Entity("Govor.Core.Models.ChatGroup", b => + { + b.Navigation("Admins"); + + b.Navigation("InviteCodes"); + + b.Navigation("Members"); + + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Govor.Core.Models.Invitation", b => + { + b.Navigation("Users"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.Message", b => + { + b.Navigation("MediaAttachments"); + + b.Navigation("MessageViews"); + + b.Navigation("Reactions"); + }); + + modelBuilder.Entity("Govor.Core.Models.PrivateChat", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.User", b => + { + b.Navigation("ReceivedFriendRequests"); + + b.Navigation("SentFriendRequests"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Govor.Data/Migrations/20250718130008_usersessions.cs b/Govor.Data/Migrations/20250718130008_usersessions.cs new file mode 100644 index 0000000..a9f01c0 --- /dev/null +++ b/Govor.Data/Migrations/20250718130008_usersessions.cs @@ -0,0 +1,53 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Govor.Data.Migrations +{ + /// + public partial class usersessions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "UserSessions", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + UserId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + RefreshToken = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + DeviceInfo = table.Column(type: "varchar(200)", maxLength: 200, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + ExpiresAt = table.Column(type: "datetime(6)", nullable: false), + IsRevoked = table.Column(type: "tinyint(1)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserSessions", x => x.Id); + table.ForeignKey( + name: "FK_UserSessions_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_UserSessions_UserId", + table: "UserSessions", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UserSessions"); + } + } +} diff --git a/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs b/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs index 33d36a5..49f1d34 100644 --- a/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs +++ b/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs @@ -364,6 +364,40 @@ namespace Govor.Data.Migrations b.ToTable("PrivateChats"); }); + modelBuilder.Entity("Govor.Core.Models.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DeviceInfo") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime(6)"); + + b.Property("IsRevoked") + .HasColumnType("tinyint(1)"); + + b.Property("RefreshToken") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserSessions"); + }); + modelBuilder.Entity("Govor.Core.Models.Users.Admin", b => { b.Property("UserId") @@ -529,6 +563,15 @@ namespace Govor.Data.Migrations .IsRequired(); }); + modelBuilder.Entity("Govor.Core.Models.UserSession", b => + { + b.HasOne("Govor.Core.Models.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Govor.Core.Models.Users.Admin", b => { b.HasOne("Govor.Core.Models.Users.User", "User") diff --git a/Govor.Data/Repositories/UserSessionsRepository.cs b/Govor.Data/Repositories/UserSessionsRepository.cs new file mode 100644 index 0000000..a4371f4 --- /dev/null +++ b/Govor.Data/Repositories/UserSessionsRepository.cs @@ -0,0 +1,162 @@ +using Govor.Core.Infrastructure.Extensions; +using Govor.Core.Models; +using Govor.Core.Repositories.UserSessionsRepository; +using Govor.Data.Repositories.Exceptions; +using Microsoft.EntityFrameworkCore; + +namespace Govor.Data.Repositories; + +public class UserSessionsRepository : IUserSessionsRepository +{ + private GovorDbContext _context; + + public UserSessionsRepository(GovorDbContext context) + { + _context = context; + } + + public async Task> GetAllAsync() + { + return await _context.UserSessions + .AsNoTracking() + .ToListOrThrowIfEmpty(new NotFoundException("Database is empty")); + } + + public async Task GetByIdAsync(Guid sessionId) + { + return await _context.UserSessions + .AsNoTracking() + .FirstOrDefaultAsync(session => session.Id == sessionId) + ?? throw new NotFoundByKeyException(sessionId, "Session with given id does not exist"); + } + + public async Task> GetByUserIdAsync(Guid userId) + { + return await _context.UserSessions + .AsNoTracking() + .Where(session => session.UserId == userId) + .ToListOrThrowIfEmpty(new NotFoundByKeyException(userId, "Sessions with given user id does not exist")); + } + + public async Task> GetByCreatedAtAsync(DateTime createdAt) + { + return await _context.UserSessions + .AsNoTracking() + .Where(session => session.CreatedAt == createdAt) + .ToListOrThrowIfEmpty(new NotFoundByKeyException(createdAt, "Sessions with given created at does not exist")); + } + + public async Task> GetByExpiresAtAsync(DateTime expiresAt) + { + return await _context.UserSessions + .AsNoTracking() + .Where(session => session.ExpiresAt == expiresAt) + .ToListOrThrowIfEmpty(new NotFoundByKeyException(expiresAt, "Sessions with given expires at does not exist")); + } + + public async Task> GetByRevokedAsync(bool isRevoked) + { + return await _context.UserSessions + .AsNoTracking() + .Where(session => session.IsRevoked == isRevoked) + .ToListOrThrowIfEmpty(new NotFoundByKeyException(isRevoked, "Sessions is revoked does not exist")); + } + + public async Task AddAsync(UserSession userSession) + { + try + { + _context.UserSessions.Add(userSession); + await _context.SaveChangesAsync(); + } + catch (Exception ex) + { + throw new AdditionException("Failed to add user session", ex); + } + } + + public async Task UpdateAsync(UserSession userSession) + { + try + { + //_validator.Validate(user); + + var rowsAffected = await _context.UserSessions + .Where(u => u.Id == userSession.Id) + .ExecuteUpdateAsync(u => u + .SetProperty(a => a.UserId, userSession.UserId) + .SetProperty(u => u.RefreshToken, userSession.RefreshToken) + .SetProperty(u => u.DeviceInfo, userSession.DeviceInfo) + .SetProperty(u => u.CreatedAt, userSession.CreatedAt) + .SetProperty(u => u.ExpiresAt, userSession.ExpiresAt) + .SetProperty(u => u.IsRevoked, userSession.IsRevoked) + ); + + if (rowsAffected == 0) + throw new UpdateException($"Not found user session by given id {userSession.Id}"); + } + catch (Exception ex) + { + throw new UpdateException($"Error when updating the user session {userSession.Id}", ex); + } + } + + public async Task RemoveAsync(Guid sessionId) + { + try + { + var result = await GetByIdAsync(sessionId); + + _context.UserSessions.Remove(result); + await _context.SaveChangesAsync(); + } + catch (NotFoundByKeyException ex) + { + throw new RemoveException($"Not found session by given id {sessionId}", ex); + } + catch (Exception ex) + { + throw new RemoveException("Error when removing the session", ex); + } + } + + public async Task RemoveByUserIdAsync(Guid userId) + { + try + { + var result = await GetByUserIdAsync(userId); + + _context.UserSessions.RemoveRange(result); + await _context.SaveChangesAsync(); + } + catch (NotFoundByKeyException ex) + { + throw new RemoveException($"Not found user sessions by given user id {userId}", ex); + } + catch (Exception ex) + { + throw new RemoveException("Error when removing the user sessions", ex); + } + } + + public bool Exist(Guid sessionId) + { + return _context.UserSessions.Any(session => session.Id == sessionId); + } + + public bool Exist(string refresh) + { + return _context.UserSessions.Any(session => session.RefreshToken == refresh); + } + + public bool Exist(UserSession userSession) + { + return _context.UserSessions.Any(session => session.Id == userSession.Id && + session.RefreshToken == userSession.RefreshToken && + session.IsRevoked == userSession.IsRevoked && + session.CreatedAt == userSession.CreatedAt && + session.ExpiresAt == userSession.ExpiresAt && + session.DeviceInfo == userSession.DeviceInfo && + session.UserId == userSession.UserId); + } +} \ No newline at end of file diff --git a/qodana.yaml b/qodana.yaml new file mode 100644 index 0000000..f2bd515 --- /dev/null +++ b/qodana.yaml @@ -0,0 +1,29 @@ +#-------------------------------------------------------------------------------# +# Qodana analysis is configured by qodana.yaml file # +# https://www.jetbrains.com/help/qodana/qodana-yaml.html # +#-------------------------------------------------------------------------------# +version: "1.0" + +#Specify IDE code to run analysis without container (Applied in CI/CD pipeline) +ide: QDNET + +#Specify inspection profile for code analysis +profile: + name: qodana.starter + +#Enable inspections +#include: +# - name: + +#Disable inspections +#exclude: +# - name: +# paths: +# - + +#Execute shell command before Qodana execution (Applied in CI/CD pipeline) +#bootstrap: sh ./prepare-qodana.sh + +#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline) +#plugins: +# - id: #(plugin id can be found at https://plugins.jetbrains.com) From ff873ae17957169b4c388eb8315729ffe8f85ea3 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Sat, 19 Jul 2025 17:51:06 +0700 Subject: [PATCH 049/101] Implement refresh token flow and refactor session handling Added refresh token endpoint and controller, introduced IUserSessionRefresher and UserSessionRefresher for token renewal, and updated session handling to return both access and refresh tokens. Refactored AuthController, tests, and related interfaces to support new token flow. Fixed JwtAccessOption property typo, updated configuration, and extended UserSessionsRepository to support lookup by refresh token. --- .../Controllers/AuthControllerTests.cs | 20 ++- .../{ => Authentication}/AuthController.cs | 48 ++----- .../Authentication/RefreshController.cs | 58 ++++++++ .../Friends/FriendsRequestQueryController.cs | 4 +- Govor.API/Extensions/AddOptionExtensions.cs | 14 ++ .../ConfigurationProgramExtensions.cs | 2 +- Govor.API/Program.cs | 4 +- Govor.API/appsettings.json | 2 +- .../Authentication/JwtServiceTests.cs | 4 +- .../UserSessions/UserSessionOpenerTests.cs | 85 ++++++----- .../UserSessions/UserSessionRefresherTests.cs | 135 ++++++++++++++++++ .../UserSession/IUserSessionOpener.cs | 2 +- .../UserSession/IUserSessionRefresher.cs | 8 ++ .../Authentication/JwtAccessOption.cs | 2 +- .../Authentication/JwtRefreshOption.cs | 2 +- .../Services/Authentication/JwtService.cs | 6 +- .../UserSessions/UserSessionOpener.cs | 79 ++++++---- .../UserSessions/UserSessionRefresher.cs | 74 ++++++++++ .../Requests/RefreshTokenRequest.cs | 6 + .../Responses/RefreshTokenResponse.cs | 7 + Govor.Core/Models/UserSession.cs | 2 +- .../IUserSessionsReader.cs | 1 + .../PrivateChatsRepositoryTests.cs | 1 - .../UserSessionsRepositoryTests.cs | 35 +++++ .../Repositories/UserSessionsRepository.cs | 8 ++ 25 files changed, 485 insertions(+), 124 deletions(-) rename Govor.API/Controllers/{ => Authentication}/AuthController.cs (74%) create mode 100644 Govor.API/Controllers/Authentication/RefreshController.cs create mode 100644 Govor.API/Extensions/AddOptionExtensions.cs create mode 100644 Govor.Application.Tests/Services/UserSessions/UserSessionRefresherTests.cs create mode 100644 Govor.Application/Interfaces/UserSession/IUserSessionRefresher.cs create mode 100644 Govor.Application/Services/UserSessions/UserSessionRefresher.cs create mode 100644 Govor.Contracts/Requests/RefreshTokenRequest.cs create mode 100644 Govor.Contracts/Responses/RefreshTokenResponse.cs diff --git a/Govor.API.Tests/IntegrationTests/Controllers/AuthControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/AuthControllerTests.cs index 994f1ec..d216655 100644 --- a/Govor.API.Tests/IntegrationTests/Controllers/AuthControllerTests.cs +++ b/Govor.API.Tests/IntegrationTests/Controllers/AuthControllerTests.cs @@ -1,5 +1,5 @@ using AutoFixture; -using Govor.API.Controllers; +using Govor.API.Controllers.Authentication; using Govor.Application.Exceptions.AuthService; using Govor.Application.Exceptions.InvitesService; using Govor.Application.Interfaces.Authentication; @@ -50,7 +50,7 @@ public class AuthControllerTests // Arrange var request = _fixture.Create(); var invitation = _fixture.Create(); - var token = _fixture.Create(); + var token = _fixture.Create(); var user = _fixture.Build() .With(x => x.Username).Create(); @@ -70,8 +70,11 @@ public class AuthControllerTests // Assert Assert.That(result, Is.InstanceOf()); var okResult = result as OkObjectResult; - dynamic value = okResult.Value; - Assert.That((string)value.GetType().GetProperty("token").GetValue(value, null), Is.EqualTo(token)); + + var response = okResult?.Value as RefreshResult; + Assert.That(response, Is.Not.Null); + Assert.That(response.accessToken, Is.EqualTo(token.accessToken)); + Assert.That(response.refreshToken, Is.EqualTo(token.refreshToken)); } [Test] @@ -149,7 +152,7 @@ public class AuthControllerTests { // Arrange var loginRequest = _fixture.Create(); - var token = _fixture.Create(); + var token = _fixture.Create(); var user = _fixture.Build() .With(x => x.Username).Create(); @@ -164,8 +167,11 @@ public class AuthControllerTests // Assert Assert.That(result, Is.InstanceOf()); var okResult = result as OkObjectResult; - dynamic value = okResult.Value; - Assert.That((string)value.GetType().GetProperty("token").GetValue(value, null), Is.EqualTo(token)); + + var response = okResult?.Value as RefreshResult; + Assert.That(response, Is.Not.Null); + Assert.That(response.accessToken, Is.EqualTo(token.accessToken)); + Assert.That(response.refreshToken, Is.EqualTo(token.refreshToken)); } [Test] diff --git a/Govor.API/Controllers/AuthController.cs b/Govor.API/Controllers/Authentication/AuthController.cs similarity index 74% rename from Govor.API/Controllers/AuthController.cs rename to Govor.API/Controllers/Authentication/AuthController.cs index b6e6a7e..0b97228 100644 --- a/Govor.API/Controllers/AuthController.cs +++ b/Govor.API/Controllers/Authentication/AuthController.cs @@ -6,7 +6,7 @@ using Govor.Contracts.Requests; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -namespace Govor.API.Controllers; +namespace Govor.API.Controllers.Authentication; [ApiController] [AllowAnonymous] @@ -18,7 +18,11 @@ public class AuthController : Controller private IAccountService _accountService; private ILogger _logger; - public AuthController(IAccountService accountService, IInvitesService invitesService,IUserSessionOpener userSessionOpener, ILogger logger) + public AuthController( + IAccountService accountService, + IInvitesService invitesService, + IUserSessionOpener userSessionOpener, + ILogger logger) { _userSession = userSessionOpener; _accountService = accountService; @@ -45,7 +49,8 @@ public class AuthController : Controller var token = await _userSession.OpenSessionAsync(user, registrationRequest.DeviceInfo); _logger.LogInformation($"Session for user {user.Username} with id {user.Id} has been opened"); - return Ok(new { token }); + + return Ok(token); } catch (UserAlreadyExistException ex) { @@ -85,7 +90,7 @@ public class AuthController : Controller _logger.LogInformation($"Session for user {user.Username} with id {user.Id} has been opened"); - return Ok(new { token }); + return Ok(token); } catch (UserNotRegisteredException ex) { @@ -103,39 +108,4 @@ public class AuthController : Controller return StatusCode(500, "An unexpected error occurred. Please try again later."); } } - -/* - [RequireHttps] - [HttpPost("refresh")] - public async Task Refresh([FromBody] string refreshToken) - { - try - { - if (!ModelState.IsValid) - return BadRequest(ModelState); - - if (string.IsNullOrEmpty(refreshToken)) - throw new InvalidOperationException("Refresh token cant be empty."); - - var newAccessToken = await _accountService.RefreshTokenAsync(refreshToken); - return Ok(new { accessToken = newAccessToken }); - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, "Invalid refresh token"); - return BadRequest(ex.Message); - } - catch (UnauthorizedAccessException ex) - { - _logger.LogWarning(ex, "Refresh token failed"); - return Unauthorized("Invalid refresh token"); - } - catch (Exception ex) - { - _logger.LogError(ex, ex.Message); - return StatusCode(500, "An unexpected error occurred."); - } - } -*/ - } \ No newline at end of file diff --git a/Govor.API/Controllers/Authentication/RefreshController.cs b/Govor.API/Controllers/Authentication/RefreshController.cs new file mode 100644 index 0000000..1bdc065 --- /dev/null +++ b/Govor.API/Controllers/Authentication/RefreshController.cs @@ -0,0 +1,58 @@ +using Govor.Application.Interfaces.UserSession; +using Govor.Contracts.Requests; +using Govor.Contracts.Responses; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Govor.API.Controllers.Authentication; + +[Route("api/auth/token")] +public class RefreshController : Controller +{ + private readonly ILogger _logger; + private readonly IUserSessionRefresher _userSession; + + public RefreshController(ILogger logger, IUserSessionRefresher userSession) + { + _logger = logger; + _userSession = userSession; + } + + [RequireHttps] + [AllowAnonymous] + [HttpPost("refresh")] + public async Task Refresh([FromBody] RefreshTokenRequest refreshRequest) + { + try + { + if (!ModelState.IsValid) + return BadRequest(ModelState); + + if (string.IsNullOrEmpty(refreshRequest.RefreshToken)) + throw new InvalidOperationException("Refresh token cant be empty."); + + var result = await _userSession.RefreshTokenAsync(refreshRequest.RefreshToken); + + return Ok(new RefreshTokenResponse() + { + AccessToken = result.accessToken, + RefreshToken = result.refreshToken + }); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "Invalid refresh token"); + return BadRequest(ex.Message); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, "Refresh token failed"); + return Unauthorized("Invalid refresh token"); + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + return StatusCode(500, "An unexpected error occurred."); + } + } +} \ No newline at end of file diff --git a/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs b/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs index f3f8bda..251765e 100644 --- a/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs +++ b/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs @@ -41,7 +41,7 @@ public class FriendsRequestQueryController : Controller } catch (InvalidOperationException ex) { - _logger.LogError(ex, ex.Message); + _logger.LogWarning(ex, ex.Message); return Ok(new List()); } catch (Exception ex) @@ -67,7 +67,7 @@ public class FriendsRequestQueryController : Controller } catch (InvalidOperationException ex) { - _logger.LogError(ex, ex.Message); + _logger.LogWarning(ex, ex.Message); return Ok(new List()); } catch (Exception ex) diff --git a/Govor.API/Extensions/AddOptionExtensions.cs b/Govor.API/Extensions/AddOptionExtensions.cs new file mode 100644 index 0000000..aa0951c --- /dev/null +++ b/Govor.API/Extensions/AddOptionExtensions.cs @@ -0,0 +1,14 @@ +using Govor.Application.Services.Authentication; + +namespace Govor.API.Extensions; + +public static class AddOptionExtensions +{ + public static IServiceCollection AddOptionsConfiguration(this IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration.GetSection(nameof(JwtAccessOption))); + services.Configure(configuration.GetSection(nameof(JwtRefreshOption))); + + return services; + } +} \ No newline at end of file diff --git a/Govor.API/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Extensions/ConfigurationProgramExtensions.cs index beb91f9..d7ced36 100644 --- a/Govor.API/Extensions/ConfigurationProgramExtensions.cs +++ b/Govor.API/Extensions/ConfigurationProgramExtensions.cs @@ -71,7 +71,7 @@ public static class ConfigurationProgramExtensions // UserSession services.AddScoped(); - + services.AddScoped(); // Auto Mapper services.AddAutoMapper(typeof(MappingProfile)); } diff --git a/Govor.API/Program.cs b/Govor.API/Program.cs index b911e88..2e1886e 100644 --- a/Govor.API/Program.cs +++ b/Govor.API/Program.cs @@ -41,7 +41,7 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) ValidateLifetime = true, ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey( - Encoding.UTF8.GetBytes(builder.Configuration["JwtOption:SecretKeу"]!)) + Encoding.UTF8.GetBytes(builder.Configuration["JwtAccessOption:SecretKey"]!)) }; options.Events = new JwtBearerEvents { @@ -67,6 +67,8 @@ builder.Services.AddServices(); builder.Services.AddRepositories(); builder.Services.AddValidators(); +builder.Services.AddOptionsConfiguration(configuration); + builder.Services.AddGovorDbContext(configuration); // GovorDbContext init builder.Services.AddEndpointsApiExplorer(); diff --git a/Govor.API/appsettings.json b/Govor.API/appsettings.json index c1f2289..d01d102 100644 --- a/Govor.API/appsettings.json +++ b/Govor.API/appsettings.json @@ -9,7 +9,7 @@ "GovorDbContext": "Server=147.45.255.215;Port=3306;Database=artemy_DB;User=artemy;Password=LoxHuy))228Goy;" }, "UseMySql": true, - "AllowedHosts": "govor-team-govor-88b3.twc1.net", + "AllowedHosts": "govor-team-govor-88b3.twc1.net;localhost;localhost:7155", "JwtAccessOption": { "SecretKey": "Q89eY7zP7C4+TqLmHF4kw9xkF1E8Ru4Zpg+up9wFt9g=", "Minutes": 25 diff --git a/Govor.Application.Tests/Services/Authentication/JwtServiceTests.cs b/Govor.Application.Tests/Services/Authentication/JwtServiceTests.cs index cc0b8fe..62953df 100644 --- a/Govor.Application.Tests/Services/Authentication/JwtServiceTests.cs +++ b/Govor.Application.Tests/Services/Authentication/JwtServiceTests.cs @@ -32,7 +32,7 @@ public class JwtServiceTests _testJwtAccessOptions = new JwtAccessOption { - SecretKeу = "THIS_IS_A_TEST_SECRET_KEY_THAT_IS_LONG_ENOUGH_1234", // Ensure key size is sufficient for HMACSHA256 + SecretKey = "THIS_IS_A_TEST_SECRET_KEY_THAT_IS_LONG_ENOUGH_1234", // Ensure key size is sufficient for HMACSHA256 Minutes = 5 }; @@ -98,7 +98,7 @@ public class JwtServiceTests // Arrange var userId = Guid.NewGuid(); var handler = new JwtSecurityTokenHandler(); - var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_testJwtAccessOptions.SecretKeу)); + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_testJwtAccessOptions.SecretKey)); var now = DateTime.UtcNow; diff --git a/Govor.Application.Tests/Services/UserSessions/UserSessionOpenerTests.cs b/Govor.Application.Tests/Services/UserSessions/UserSessionOpenerTests.cs index a50ff21..490cf51 100644 --- a/Govor.Application.Tests/Services/UserSessions/UserSessionOpenerTests.cs +++ b/Govor.Application.Tests/Services/UserSessions/UserSessionOpenerTests.cs @@ -1,5 +1,3 @@ -using Govor.Application.Interfaces.Authentication; -using Govor.Application.Services.Authentication; using Govor.Application.Services.UserSessions; using Govor.Core.Models; using Govor.Core.Models.Users; @@ -7,8 +5,8 @@ using Govor.Core.Repositories.UserSessionsRepository; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Moq; - -namespace Govor.Application.Tests.Services.UserSessions; +using Govor.Application.Interfaces.Authentication; +using Govor.Application.Services.Authentication; [TestFixture] public class UserSessionOpenerTests @@ -21,6 +19,7 @@ public class UserSessionOpenerTests private User _user; private const string DeviceInfo = "Chrome on Windows"; private const string GeneratedToken = "new-refresh-token"; + private const string NewAccessToken = "new-access-token"; [SetUp] public void Setup() @@ -30,13 +29,7 @@ public class UserSessionOpenerTests _loggerMock = new Mock>(); _options = Options.Create(new JwtRefreshOption { RefreshTokenLifetimeDays = 30 }); - _service = new UserSessionOpener( - _repositoryMock.Object, - _jwtServiceMock.Object, - _options, - _loggerMock.Object - ); - + // Инициализируем пользователя ДО моков, чтобы не было null в Setup _user = new User { Id = Guid.NewGuid(), @@ -48,16 +41,22 @@ public class UserSessionOpenerTests InviteId = Guid.NewGuid() }; - _jwtServiceMock - .Setup(j => j.GenerateRefreshTokenAsync(_user)) - .ReturnsAsync(GeneratedToken); + _jwtServiceMock.Setup(j => j.GenerateRefreshTokenAsync(_user)).ReturnsAsync(GeneratedToken); + _jwtServiceMock.Setup(j => j.GenerateAccessTokenAsync(_user)).ReturnsAsync(NewAccessToken); + + _service = new UserSessionOpener( + _repositoryMock.Object, + _jwtServiceMock.Object, + _options, + _loggerMock.Object + ); } [Test] public async Task OpenSessionAsync_ShouldReturnExistingToken_IfSessionValid() { // Arrange - var session = new Core.Models.UserSession + var session = new UserSession { UserId = _user.Id, DeviceInfo = DeviceInfo, @@ -67,23 +66,24 @@ public class UserSessionOpenerTests IsRevoked = false }; - _repositoryMock - .Setup(r => r.GetByUserIdAsync(_user.Id)) - .ReturnsAsync(new List { session }); - + _repositoryMock.Setup(r => r.GetByUserIdAsync(_user.Id)).ReturnsAsync(new List { session }); + // Act var result = await _service.OpenSessionAsync(_user, DeviceInfo); - // Asser - Assert.That(result, Is.EqualTo("valid-token")); - _repositoryMock.Verify(r => r.UpdateAsync(It.IsAny()), Times.Never); + // Assert + Assert.That(result.refreshToken, Is.EqualTo(GeneratedToken)); + Assert.That(result.accessToken, Is.EqualTo(NewAccessToken)); + _repositoryMock.Verify(r => r.UpdateAsync(It.Is(s => + s.RefreshToken == GeneratedToken && + s.IsRevoked == false)), Times.Once); } [Test] public async Task OpenSessionAsync_ShouldUpdateSession_IfExpiredOrRevoked() { // Arrange - var session = new Core.Models.UserSession + var session = new UserSession { UserId = _user.Id, DeviceInfo = DeviceInfo, @@ -93,35 +93,52 @@ public class UserSessionOpenerTests IsRevoked = false }; - _repositoryMock - .Setup(r => r.GetByUserIdAsync(_user.Id)) - .ReturnsAsync(new List { session }); - + _repositoryMock.Setup(r => r.GetByUserIdAsync(_user.Id)).ReturnsAsync(new List { session }); + // Act var result = await _service.OpenSessionAsync(_user, DeviceInfo); // Assert - Assert.That(result, Is.EqualTo(GeneratedToken)); - _repositoryMock.Verify(r => r.UpdateAsync(It.Is(s => s.RefreshToken == GeneratedToken)), Times.Once); + Assert.That(result.refreshToken, Is.EqualTo(GeneratedToken)); + Assert.That(result.accessToken, Is.EqualTo(NewAccessToken)); + _repositoryMock.Verify(r => r.UpdateAsync(It.Is(s => + s.RefreshToken == GeneratedToken && + s.IsRevoked == false)), Times.Once); } [Test] public async Task OpenSessionAsync_ShouldCreateNewSession_IfNoneExists() { // Arrange - _repositoryMock - .Setup(r => r.GetByUserIdAsync(_user.Id)) - .ReturnsAsync(new List()); + _repositoryMock.Setup(r => r.GetByUserIdAsync(_user.Id)).ReturnsAsync(new List()); // Act var result = await _service.OpenSessionAsync(_user, DeviceInfo); - + // Assert - Assert.That(result, Is.EqualTo(GeneratedToken)); + Assert.That(result.refreshToken, Is.EqualTo(GeneratedToken)); + Assert.That(result.accessToken, Is.EqualTo(NewAccessToken)); _repositoryMock.Verify(r => r.AddAsync(It.Is(s => s.UserId == _user.Id && s.DeviceInfo == DeviceInfo && s.RefreshToken == GeneratedToken )), Times.Once); } + + [Test] + public async Task OpenSessionAsync_ShouldCreateNewSession_WhenNotFoundByKeyExceptionThrown() + { + // Arrange + _repositoryMock + .Setup(r => r.GetByUserIdAsync(_user.Id)) + .ThrowsAsync(new Govor.Data.Repositories.Exceptions.NotFoundByKeyException(_user.Id, "userId")); + + // Act + var result = await _service.OpenSessionAsync(_user, DeviceInfo); + + // Assert + Assert.That(result.refreshToken, Is.EqualTo(GeneratedToken)); + Assert.That(result.accessToken, Is.EqualTo(NewAccessToken)); + _repositoryMock.Verify(r => r.AddAsync(It.IsAny()), Times.Once); + } } diff --git a/Govor.Application.Tests/Services/UserSessions/UserSessionRefresherTests.cs b/Govor.Application.Tests/Services/UserSessions/UserSessionRefresherTests.cs new file mode 100644 index 0000000..0577a5c --- /dev/null +++ b/Govor.Application.Tests/Services/UserSessions/UserSessionRefresherTests.cs @@ -0,0 +1,135 @@ +using Govor.Application.Interfaces.Authentication; +using Govor.Application.Services.Authentication; +using Govor.Application.Services.UserSessions; +using Govor.Core.Models; +using Govor.Core.Models.Users; +using Govor.Core.Repositories.Users; +using Govor.Core.Repositories.UserSessionsRepository; +using Govor.Data.Repositories.Exceptions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; + +namespace Govor.Application.Tests.Services.UserSessions; + +[TestFixture] +public class UserSessionRefresherTests +{ + private Mock _sessionsRepoMock; + private Mock _usersRepoMock; + private Mock _jwtServiceMock; + private Mock> _loggerMock; + private Mock> _optionsMock; + private JwtRefreshOption _options; + private UserSessionRefresher _refresher; + private const string OldRefreshToken = "old-refresh-token"; + private const string NewRefreshToken = "new-refresh-token"; + private const string NewAccessToken = "new-access-token"; + private User _user; + private UserSession _session; + + [SetUp] + public void Setup() + { + _sessionsRepoMock = new Mock(); + _usersRepoMock = new Mock(); + _jwtServiceMock = new Mock(); + _loggerMock = new Mock>(); + _optionsMock = new Mock>(); + + _options = new JwtRefreshOption { RefreshTokenLifetimeDays = 30 }; + + _optionsMock.SetupGet(o => o.Value).Returns(_options); + + _refresher = new UserSessionRefresher( + _sessionsRepoMock.Object, + _loggerMock.Object, + _usersRepoMock.Object, + _optionsMock.Object, + _jwtServiceMock.Object); + + _user = new User + { + Id = Guid.NewGuid(), + Username = "TestUser", + PasswordHash = "hash", + InviteId = Guid.NewGuid() + }; + + _session = new UserSession + { + RefreshToken = OldRefreshToken, + UserId = _user.Id, + DeviceInfo = "Chrome", + CreatedAt = DateTime.UtcNow.AddDays(-5), + ExpiresAt = DateTime.UtcNow.AddDays(5), + IsRevoked = false + }; + } + + [Test] + public async Task RefreshTokenAsync_ValidToken_ReturnsNewTokensAndCreatesNewSession() + { + // Arrange + _sessionsRepoMock.Setup(r => r.GetByRefreshTokenAsync(OldRefreshToken)).ReturnsAsync(_session); + _usersRepoMock.Setup(r => r.FindByIdAsync(_user.Id)).ReturnsAsync(_user); + _jwtServiceMock.Setup(j => j.GenerateAccessTokenAsync(_user)).ReturnsAsync(NewAccessToken); + _jwtServiceMock.Setup(j => j.GenerateRefreshTokenAsync(_user)).ReturnsAsync(NewRefreshToken); + + // Act + var result = await _refresher.RefreshTokenAsync(OldRefreshToken); + + // Assert + Assert.That(result.accessToken, Is.EqualTo(NewAccessToken)); + Assert.That(result.refreshToken, Is.EqualTo(NewRefreshToken)); + Assert.That(_session.IsRevoked, Is.True); + + _sessionsRepoMock.Verify(r => r.UpdateAsync(_session), Times.Once); + _sessionsRepoMock.Verify(r => r.AddAsync(It.Is(s => + s.UserId == _user.Id && + s.RefreshToken == NewRefreshToken && + s.DeviceInfo == _session.DeviceInfo)), Times.Once); + } + + [Test] + public void RefreshTokenAsync_RevokedToken_ThrowsUnauthorizedAccessException() + { + // Arrange + _session.IsRevoked = true; + _sessionsRepoMock.Setup(r => r.GetByRefreshTokenAsync(OldRefreshToken)).ReturnsAsync(_session); + + // Act & Assert + var ex = Assert.ThrowsAsync(async () => + await _refresher.RefreshTokenAsync(OldRefreshToken)); + + Assert.That(ex.Message, Contains.Substring("Refresh token is invalid or expired")); + } + + [Test] + public void RefreshTokenAsync_ExpiredToken_ThrowsUnauthorizedAccessException() + { + // Arrange + _session.ExpiresAt = DateTime.UtcNow.AddMinutes(-1); + _sessionsRepoMock.Setup(r => r.GetByRefreshTokenAsync(OldRefreshToken)).ReturnsAsync(_session); + + // Act & Assert + var ex = Assert.ThrowsAsync(async () => + await _refresher.RefreshTokenAsync(OldRefreshToken)); + + Assert.That(ex.Message, Contains.Substring("Refresh token is invalid or expired")); + } + + [Test] + public void RefreshTokenAsync_TokenNotFound_ThrowsUnauthorizedAccessException() + { + // Arrange + _sessionsRepoMock.Setup(r => r.GetByRefreshTokenAsync(OldRefreshToken)) + .ThrowsAsync(new NotFoundByKeyException("token", OldRefreshToken)); + // Act & Assert + var ex = Assert.ThrowsAsync(async () => + await _refresher.RefreshTokenAsync(OldRefreshToken)); + + Assert.That(ex.Message, Contains.Substring("Invalid refresh token")); + } +} + diff --git a/Govor.Application/Interfaces/UserSession/IUserSessionOpener.cs b/Govor.Application/Interfaces/UserSession/IUserSessionOpener.cs index 641084d..243688c 100644 --- a/Govor.Application/Interfaces/UserSession/IUserSessionOpener.cs +++ b/Govor.Application/Interfaces/UserSession/IUserSessionOpener.cs @@ -5,6 +5,6 @@ namespace Govor.Application.Interfaces.UserSession; public interface IUserSessionOpener { - Task OpenSessionAsync(User user, string deviceInfo); + Task OpenSessionAsync(User user, string deviceInfo); } diff --git a/Govor.Application/Interfaces/UserSession/IUserSessionRefresher.cs b/Govor.Application/Interfaces/UserSession/IUserSessionRefresher.cs new file mode 100644 index 0000000..1020f96 --- /dev/null +++ b/Govor.Application/Interfaces/UserSession/IUserSessionRefresher.cs @@ -0,0 +1,8 @@ +namespace Govor.Application.Interfaces.UserSession; + +public interface IUserSessionRefresher +{ + Task RefreshTokenAsync(string refreshToken); +} + +public record RefreshResult(string refreshToken, string accessToken); \ No newline at end of file diff --git a/Govor.Application/Services/Authentication/JwtAccessOption.cs b/Govor.Application/Services/Authentication/JwtAccessOption.cs index 6308a92..a898c5e 100644 --- a/Govor.Application/Services/Authentication/JwtAccessOption.cs +++ b/Govor.Application/Services/Authentication/JwtAccessOption.cs @@ -1,6 +1,6 @@ namespace Govor.Application.Services.Authentication; public class JwtAccessOption { - public string SecretKeу {get; set;} + public string SecretKey {get; set;} public int Minutes { get; set; } } \ No newline at end of file diff --git a/Govor.Application/Services/Authentication/JwtRefreshOption.cs b/Govor.Application/Services/Authentication/JwtRefreshOption.cs index 3e88c1a..51db7ad 100644 --- a/Govor.Application/Services/Authentication/JwtRefreshOption.cs +++ b/Govor.Application/Services/Authentication/JwtRefreshOption.cs @@ -3,4 +3,4 @@ namespace Govor.Application.Services.Authentication; public class JwtRefreshOption { public int RefreshTokenLifetimeDays { get; set; } -} \ No newline at end of file +} diff --git a/Govor.Application/Services/Authentication/JwtService.cs b/Govor.Application/Services/Authentication/JwtService.cs index 7110fbd..12da414 100644 --- a/Govor.Application/Services/Authentication/JwtService.cs +++ b/Govor.Application/Services/Authentication/JwtService.cs @@ -30,7 +30,7 @@ public class JwtService : IJwtService }; var singing = new SigningCredentials( - new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtAccessOption.SecretKeу)), + new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtAccessOption.SecretKey)), SecurityAlgorithms.HmacSha256Signature); var token = new JwtSecurityToken( @@ -43,7 +43,7 @@ public class JwtService : IJwtService public async Task GenerateRefreshTokenAsync(User user) { - var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtAccessOption.SecretKeу)); + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtAccessOption.SecretKey)); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var claims = new[] @@ -68,7 +68,7 @@ public class JwtService : IJwtService ValidateAudience = false, ValidateIssuer = false, ValidateIssuerSigningKey = true, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtAccessOption.SecretKeу)), + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtAccessOption.SecretKey)), ValidateLifetime = false // << important }; diff --git a/Govor.Application/Services/UserSessions/UserSessionOpener.cs b/Govor.Application/Services/UserSessions/UserSessionOpener.cs index e0519f3..5fe5e59 100644 --- a/Govor.Application/Services/UserSessions/UserSessionOpener.cs +++ b/Govor.Application/Services/UserSessions/UserSessionOpener.cs @@ -3,6 +3,7 @@ using Govor.Application.Interfaces.UserSession; using Govor.Application.Services.Authentication; using Govor.Core.Models.Users; using Govor.Core.Repositories.UserSessionsRepository; +using Govor.Data.Repositories.Exceptions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -11,58 +12,78 @@ namespace Govor.Application.Services.UserSessions; public class UserSessionOpener : IUserSessionOpener { private readonly IUserSessionsRepository _repository; - private readonly IJwtService _jwtService; private readonly ILogger _logger; private readonly JwtRefreshOption _options; + private readonly IJwtService _jwtService; - public UserSessionOpener(IUserSessionsRepository repository, IJwtService jwtService, IOptions options, ILogger logger) + public UserSessionOpener( + IUserSessionsRepository repository, + IJwtService jwtService, + IOptions options, + ILogger logger) { _jwtService = jwtService; _repository = repository; _logger = logger; _options = options.Value; } - public async Task OpenSessionAsync(User user, string deviceInfo) + + public async Task OpenSessionAsync(User user, string deviceInfo) { _logger.LogInformation($"Opening session for user {user.Id} on device '{deviceInfo}'"); - var sessions = await _repository.GetByUserIdAsync(user.Id); - var session = sessions.FirstOrDefault(s => s.DeviceInfo == deviceInfo); - - var newRefreshToken = await _jwtService.GenerateRefreshTokenAsync(user); - var newExpiresAt = DateTime.UtcNow.AddDays(_options.RefreshTokenLifetimeDays); - - if (session is not null) + try { - if (session.IsRevoked || session.ExpiresAt <= DateTime.UtcNow) + var sessions = await _repository.GetByUserIdAsync(user.Id); + var session = sessions.FirstOrDefault(s => s.DeviceInfo == deviceInfo); + + var newRefreshToken = await _jwtService.GenerateRefreshTokenAsync(user); + var accessToken = await _jwtService.GenerateAccessTokenAsync(user); + + var newExpiresAt = DateTime.UtcNow.AddDays(_options.RefreshTokenLifetimeDays); + + if (session is not null) { - // Update Session + // Всегда обновляем токен и дату session.RefreshToken = newRefreshToken; session.ExpiresAt = newExpiresAt; session.CreatedAt = DateTime.UtcNow; session.IsRevoked = false; await _repository.UpdateAsync(session); - _logger.LogInformation($"Updated expired/revoked session for user {user.Id} on device '{deviceInfo}'"); + _logger.LogInformation($"Updated session for user {user.Id} on device '{deviceInfo}'"); + + return new RefreshResult(session.RefreshToken, accessToken); } - - return session.RefreshToken; + + return await OpenNewSession(); } - - // New Session - var newSession = new Core.Models.UserSession + catch (NotFoundByKeyException ex) { - UserId = user.Id, - DeviceInfo = deviceInfo, - RefreshToken = newRefreshToken, - CreatedAt = DateTime.UtcNow, - ExpiresAt = newExpiresAt, - IsRevoked = false - }; + return await OpenNewSession(); + } + + async Task OpenNewSession() + { + var newRefreshToken = await _jwtService.GenerateRefreshTokenAsync(user); + var accessToken = await _jwtService.GenerateAccessTokenAsync(user); + + var newSession = new Core.Models.UserSession + { + UserId = user.Id, + DeviceInfo = deviceInfo, + RefreshToken = newRefreshToken, + CreatedAt = DateTime.UtcNow, + ExpiresAt = DateTime.UtcNow.AddDays(_options.RefreshTokenLifetimeDays), + IsRevoked = false + }; - await _repository.AddAsync(newSession); - _logger.LogInformation($"Created new session for user {user.Id} on device '{deviceInfo}'"); + await _repository.AddAsync(newSession); + + _logger.LogInformation($"Created new session for user {user.Id} on device '{deviceInfo}'"); - return newRefreshToken; + return new RefreshResult(newRefreshToken, accessToken); + } } -} \ No newline at end of file +} + \ No newline at end of file diff --git a/Govor.Application/Services/UserSessions/UserSessionRefresher.cs b/Govor.Application/Services/UserSessions/UserSessionRefresher.cs new file mode 100644 index 0000000..1404404 --- /dev/null +++ b/Govor.Application/Services/UserSessions/UserSessionRefresher.cs @@ -0,0 +1,74 @@ +using Govor.Application.Interfaces.Authentication; +using Govor.Application.Interfaces.UserSession; +using Govor.Application.Services.Authentication; +using Govor.Core.Models; +using Govor.Core.Repositories.Users; +using Govor.Core.Repositories.UserSessionsRepository; +using Govor.Data.Repositories.Exceptions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Govor.Application.Services.UserSessions; + +public class UserSessionRefresher : IUserSessionRefresher +{ + private readonly IUserSessionsRepository _sessionsRepository; + private readonly ILogger _logger; + private readonly IUsersRepository _usersRepository; + private readonly JwtRefreshOption _options; + private readonly IJwtService _jwtService; + + public UserSessionRefresher( + IUserSessionsRepository sessionsRepository, + ILogger logger, + IUsersRepository usersRepository, + IOptions options, + IJwtService jwtService) + { + _sessionsRepository = sessionsRepository; + _logger = logger; + _usersRepository = usersRepository; + _options = options.Value; + _jwtService = jwtService; + } + + public async Task RefreshTokenAsync(string refreshToken) + { + try + { + var session = await _sessionsRepository.GetByRefreshTokenAsync(refreshToken); + + if (session.IsRevoked || session.ExpiresAt <= DateTime.UtcNow) + throw new UnauthorizedAccessException("Refresh token is invalid or expired"); + + session.IsRevoked = true; + await _sessionsRepository.UpdateAsync(session); + + // Find user + var user = await _usersRepository.FindByIdAsync(session.UserId); + + // New tokens + var newAccessToken = await _jwtService.GenerateAccessTokenAsync(user); + var newRefreshToken = await _jwtService.GenerateRefreshTokenAsync(user); + + // Opening new session + var newSession = new UserSession + { + UserId = user.Id, + RefreshToken = newRefreshToken, + DeviceInfo = session.DeviceInfo, + CreatedAt = DateTime.UtcNow, + ExpiresAt = DateTime.UtcNow.AddDays(_options.RefreshTokenLifetimeDays) + }; + + await _sessionsRepository.AddAsync(newSession); + + return new RefreshResult(newRefreshToken, newAccessToken); + } + catch (NotFoundByKeyException ex) + { + _logger.LogWarning(ex, ex.Message); + throw new UnauthorizedAccessException("Invalid refresh token", ex); + } + } +} \ No newline at end of file diff --git a/Govor.Contracts/Requests/RefreshTokenRequest.cs b/Govor.Contracts/Requests/RefreshTokenRequest.cs new file mode 100644 index 0000000..c4f402d --- /dev/null +++ b/Govor.Contracts/Requests/RefreshTokenRequest.cs @@ -0,0 +1,6 @@ +namespace Govor.Contracts.Requests; + +public class RefreshTokenRequest +{ + public string RefreshToken { get; set; } = null!; +} diff --git a/Govor.Contracts/Responses/RefreshTokenResponse.cs b/Govor.Contracts/Responses/RefreshTokenResponse.cs new file mode 100644 index 0000000..b2d598a --- /dev/null +++ b/Govor.Contracts/Responses/RefreshTokenResponse.cs @@ -0,0 +1,7 @@ +namespace Govor.Contracts.Responses; + +public class RefreshTokenResponse +{ + public string RefreshToken { get; set; } = null!; + public string AccessToken { get; set; } = null!; +} \ No newline at end of file diff --git a/Govor.Core/Models/UserSession.cs b/Govor.Core/Models/UserSession.cs index 23ce695..ce6cdaa 100644 --- a/Govor.Core/Models/UserSession.cs +++ b/Govor.Core/Models/UserSession.cs @@ -5,7 +5,7 @@ public class UserSession public Guid Id { get; set; } = Guid.NewGuid(); public Guid UserId { get; set; } public string RefreshToken { get; set; } = string.Empty; - public string DeviceInfo { get; set; } = string.Empty; // например, "Chrome on Windows" + public string DeviceInfo { get; set; } = string.Empty; // "Chrome on Windows" public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime ExpiresAt { get; set; } public bool IsRevoked { get; set; } = false; diff --git a/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsReader.cs b/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsReader.cs index 0c6e51f..6d4fc71 100644 --- a/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsReader.cs +++ b/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsReader.cs @@ -10,4 +10,5 @@ public interface IUserSessionsReader public Task> GetByCreatedAtAsync(DateTime createdAt); public Task> GetByExpiresAtAsync(DateTime createdAt); public Task> GetByRevokedAsync(bool isRevoked); + public Task GetByRefreshTokenAsync(string refreshToken); } \ No newline at end of file diff --git a/Govor.Data.Tests/Repositories/PrivateChatsRepositoryTests.cs b/Govor.Data.Tests/Repositories/PrivateChatsRepositoryTests.cs index 7e26ff4..15d6b07 100644 --- a/Govor.Data.Tests/Repositories/PrivateChatsRepositoryTests.cs +++ b/Govor.Data.Tests/Repositories/PrivateChatsRepositoryTests.cs @@ -200,5 +200,4 @@ public class PrivateChatsRepositoryTests Assert.That(result1, Is.False); Assert.That(result2, Is.False); } - } \ No newline at end of file diff --git a/Govor.Data.Tests/Repositories/UserSessionsRepositoryTests.cs b/Govor.Data.Tests/Repositories/UserSessionsRepositoryTests.cs index 632a8bd..37dd421 100644 --- a/Govor.Data.Tests/Repositories/UserSessionsRepositoryTests.cs +++ b/Govor.Data.Tests/Repositories/UserSessionsRepositoryTests.cs @@ -202,6 +202,41 @@ public class UserSessionsRepositoryTests Assert.ThrowsAsync>(async () => await repository.GetByRevokedAsync(false)); } + [Test] + public async Task Given_ValidRefreshToken_When_GetByRefreshTokenAsync_ShouldReturnSession() + { + // Arrange + var random = new Random(); + var sessions = _fixture.Build() + .With(f => f.IsRevoked, true) + .CreateMany(random.Next(2, 10)).ToList(); + + var token = sessions.First().RefreshToken; + + await using var context = new GovorDbContext(_options); + var repository = new UserSessionsRepository(context); + + context.UserSessions.AddRange(sessions); + await context.SaveChangesAsync(); + + // Act + var result = await repository.GetByRefreshTokenAsync(token); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result, Is.EqualTo(sessions.First())); + } + + [Test] + public void Given_InvalidRefreshToken_When_GetByRefreshTokenAsync_Should_Throw_NotFoundByKeyException() + { + // Arrange + using var context = new GovorDbContext(_options); + var repository = new UserSessionsRepository(context); + // Act & Assert + Assert.ThrowsAsync>(async () => await repository.GetByRefreshTokenAsync(_fixture.Create())); + } + [Test] public async Task Given_ValidUserSessions_When_AddAsync_Then_UserSessionsAdded() { diff --git a/Govor.Data/Repositories/UserSessionsRepository.cs b/Govor.Data/Repositories/UserSessionsRepository.cs index a4371f4..c4ed8d1 100644 --- a/Govor.Data/Repositories/UserSessionsRepository.cs +++ b/Govor.Data/Repositories/UserSessionsRepository.cs @@ -62,6 +62,14 @@ public class UserSessionsRepository : IUserSessionsRepository .ToListOrThrowIfEmpty(new NotFoundByKeyException(isRevoked, "Sessions is revoked does not exist")); } + public async Task GetByRefreshTokenAsync(string refreshToken) + { + return await _context.UserSessions + .AsNoTracking() + .FirstOrDefaultAsync(session => session.RefreshToken == refreshToken) + ?? throw new NotFoundByKeyException(refreshToken, "Session with given refresh token does not exist"); + } + public async Task AddAsync(UserSession userSession) { try From f524a9f0b7024430d98f3455c9a3bc4e37d862ba Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Sat, 19 Jul 2025 19:08:21 +0700 Subject: [PATCH 050/101] Refactor console client to new Govor.ConsoleClient project Removed Govor.Console implementation and introduced a new Govor.ConsoleClient project with a modular command-based architecture. Added dependency injection, command dispatcher, middleware pipeline, logging, and interactive command support. Updated solution and project files to reflect the new structure. --- Govor.Console/FriendsClient.cs | 57 ------------ Govor.Console/FriendsHubClient.cs | 70 --------------- Govor.Console/HttpClientService.cs | 87 ------------------- Govor.Console/Program.cs | 53 ----------- Govor.ConsoleClient/App.cs | 27 ++++++ Govor.ConsoleClient/Commands/HelpCommand.cs | 22 +++++ Govor.ConsoleClient/Commands/ICommand.cs | 17 ++++ .../Commands/IInteractiveCommand.cs | 7 ++ .../Commands/SendMessageCommand.cs | 44 ++++++++++ Govor.ConsoleClient/DependencyInjection.cs | 47 ++++++++++ .../Govor.ConsoleClient.csproj | 7 ++ Govor.ConsoleClient/Program.cs | 18 ++++ .../Services/CommandContext.cs | 17 ++++ .../Services/CommandDispatcher.cs | 41 +++++++++ Govor.ConsoleClient/Services/ConsoleLogger.cs | 45 ++++++++++ Govor.ConsoleClient/Services/ILogger.cs | 10 +++ Govor.ConsoleClient/Services/InputPipeline.cs | 49 +++++++++++ .../Middleware/ExceptionHandlingMiddleware.cs | 23 +++++ .../Services/Middleware/ICommandMiddleware.cs | 6 ++ .../Services/MiddlewarePipeline.cs | 29 +++++++ Govor.sln | 2 +- 21 files changed, 410 insertions(+), 268 deletions(-) delete mode 100644 Govor.Console/FriendsClient.cs delete mode 100644 Govor.Console/FriendsHubClient.cs delete mode 100644 Govor.Console/HttpClientService.cs delete mode 100644 Govor.Console/Program.cs create mode 100644 Govor.ConsoleClient/App.cs create mode 100644 Govor.ConsoleClient/Commands/HelpCommand.cs create mode 100644 Govor.ConsoleClient/Commands/ICommand.cs create mode 100644 Govor.ConsoleClient/Commands/IInteractiveCommand.cs create mode 100644 Govor.ConsoleClient/Commands/SendMessageCommand.cs create mode 100644 Govor.ConsoleClient/DependencyInjection.cs rename Govor.Console/Govor.Console.csproj => Govor.ConsoleClient/Govor.ConsoleClient.csproj (81%) create mode 100644 Govor.ConsoleClient/Program.cs create mode 100644 Govor.ConsoleClient/Services/CommandContext.cs create mode 100644 Govor.ConsoleClient/Services/CommandDispatcher.cs create mode 100644 Govor.ConsoleClient/Services/ConsoleLogger.cs create mode 100644 Govor.ConsoleClient/Services/ILogger.cs create mode 100644 Govor.ConsoleClient/Services/InputPipeline.cs create mode 100644 Govor.ConsoleClient/Services/Middleware/ExceptionHandlingMiddleware.cs create mode 100644 Govor.ConsoleClient/Services/Middleware/ICommandMiddleware.cs create mode 100644 Govor.ConsoleClient/Services/MiddlewarePipeline.cs diff --git a/Govor.Console/FriendsClient.cs b/Govor.Console/FriendsClient.cs deleted file mode 100644 index 0af8398..0000000 --- a/Govor.Console/FriendsClient.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System.Net.Http.Json; -using Govor.Contracts.DTOs; -using System.Collections.Generic; -using System.Net.Http; -using System.Threading.Tasks; // Added for Task - -namespace Govor.ConsoleClient -{ - public class FriendsClient - { - private readonly HttpClient _client; - - public FriendsClient(HttpClient client) - { - _client = client; - } - - public async Task> SearchAsync(string query) - { - var response = await _client.GetAsync($"/api/friends/search?query={query}"); - response.EnsureSuccessStatusCode(); - - return await response.Content.ReadFromJsonAsync>() ?? new List(); - } - - // SendFriendRequestAsync was removed as the SendFriendRequestCommand now uses SignalR directly. - // public async Task SendFriendRequestAsync(Guid targetUserId) - // { - // var response = await _client.PostAsync($"api/friends/request?targetUserId={targetUserId}", null); - // response.EnsureSuccessStatusCode(); - // } - - public async Task> GetIncomingRequestsAsync() - { - var response = await _client.GetAsync("/api/friends/requests"); - response.EnsureSuccessStatusCode(); - - return await response.Content.ReadFromJsonAsync>() ?? new List(); - } - - // AcceptFriendRequestAsync was removed as the AcceptFriendRequestCommand now uses SignalR directly. - // public async Task AcceptFriendRequestAsync(Guid friendshipId) // Parameter was requesterId, should be friendshipId - // { - // var response = await _client.PostAsync($"/api/friends/accept?friendshipId={friendshipId}", null); - // response.EnsureSuccessStatusCode(); - // } - - public async Task> GetFriendsAsync() - { - var response = await _client.GetAsync("/api/friends"); - response.EnsureSuccessStatusCode(); - - return await response.Content.ReadFromJsonAsync>() ?? new List(); - } - } - -} \ No newline at end of file diff --git a/Govor.Console/FriendsHubClient.cs b/Govor.Console/FriendsHubClient.cs deleted file mode 100644 index fdc342b..0000000 --- a/Govor.Console/FriendsHubClient.cs +++ /dev/null @@ -1,70 +0,0 @@ -using Microsoft.AspNetCore.SignalR.Client; -using System; -using System.Threading.Tasks; -using Govor.Contracts.Responses.SignalR; - -namespace Govor.ConsoleClient; - - -public class FriendsHubClient -{ - private readonly string _hubUrl; - private readonly string _jwtToken; - private HubConnection _connection; - - public FriendsHubClient(string hubUrl, string jwtToken) - { - _hubUrl = hubUrl; - _jwtToken = jwtToken; - } - - public async Task ConnectAsync() - { - _connection = new HubConnectionBuilder() - .WithUrl($"{_hubUrl}/friends", options => - { - options.AccessTokenProvider = () => Task.FromResult(_jwtToken); - }) - .WithAutomaticReconnect() - .Build(); - - // Событие: получен входящий запрос в друзья - _connection.On("FriendRequestReceived", userId => - { - Console.WriteLine($"📨 Friend request received from: {userId}"); - }); - - // Событие: запрос принят - _connection.On("FriendRequestAccepted", friendshipId => - { - Console.WriteLine($"✅ Friend request accepted! Friendship ID: {friendshipId}"); - }); - - // Событие: запрос отклонён - _connection.On("FriendRequestRejected", friendshipId => - { - Console.WriteLine($"❌ Friend request rejected! Friendship ID: {friendshipId}"); - }); - - await _connection.StartAsync(); - Console.WriteLine("✅ Connected to FriendsHub"); - } - - public async Task SendFriendRequestAsync(Guid targetUserId) - { - var result = await _connection.InvokeAsync>("SendRequest", targetUserId); - Console.WriteLine($"📤 Friend request sent. Status: {result.Status}"); - } - - public async Task AcceptFriendRequestAsync(Guid friendshipId) - { - var result = await _connection.InvokeAsync>("AcceptRequest", friendshipId); - Console.WriteLine($"👍 Accept result: {result.Status}"); - } - - public async Task RejectFriendRequestAsync(Guid friendshipId) - { - var result = await _connection.InvokeAsync>("RejectRequest", friendshipId); - Console.WriteLine($"👎 Reject result: {result.Status}"); - } -} diff --git a/Govor.Console/HttpClientService.cs b/Govor.Console/HttpClientService.cs deleted file mode 100644 index 9482b92..0000000 --- a/Govor.Console/HttpClientService.cs +++ /dev/null @@ -1,87 +0,0 @@ -using System.Net.Http; -using System.Net.Http.Json; -using System.Text.Json; -using System.Threading.Tasks; -using Govor.Contracts.Requests; - -namespace Govor.ConsoleClient -{ - public class HttpClientService - { - private readonly HttpClient _client; - - public HttpClientService(string baseUrl) - { - _client = new HttpClient - { - BaseAddress = new Uri(baseUrl) - }; - } - - public string GetBaseUrl() => _client.BaseAddress?.ToString() ?? string.Empty; - - public async Task GetAsync(string uri) - { - var response = await _client.GetAsync(uri); - response.EnsureSuccessStatusCode(); - return await response.Content.ReadAsStringAsync(); - } - - public async Task PostAsync(string uri, object? payload = null) - { - HttpResponseMessage response; - - if (payload is null) - response = await _client.PostAsync(uri, null); - else - response = await _client.PostAsJsonAsync(uri, payload); - - response.EnsureSuccessStatusCode(); - return await response.Content.ReadAsStringAsync(); - } - - public async Task RegisterAsync(string username, string password, string inviteCode) - { - var request = new RegistrationRequest - { - Name = username, - Password = password, - InviteLink = inviteCode - }; - - var response = await _client.PostAsJsonAsync("/api/auth/register", request); - if (response.IsSuccessStatusCode) - { - var result = await response.Content.ReadFromJsonAsync(); - return result?.Token; - } - - var error = await response.Content.ReadAsStringAsync(); - throw new Exception($"Registration failed: {error}"); - } - - public async Task LoginAsync(string username, string password) - { - var request = new LoginRequest - { - Name = username, - Password = password - }; - - var response = await _client.PostAsJsonAsync("/api/auth/login", request); - if (response.IsSuccessStatusCode) - { - var result = await response.Content.ReadFromJsonAsync(); - return result?.Token; - } - - var error = await response.Content.ReadAsStringAsync(); - throw new Exception($"Login failed: {error}"); - } - } - - public class TokenResponse - { - public string Token { get; set; } - } -} diff --git a/Govor.Console/Program.cs b/Govor.Console/Program.cs deleted file mode 100644 index 9ac0bbf..0000000 --- a/Govor.Console/Program.cs +++ /dev/null @@ -1,53 +0,0 @@ -using Govor.ConsoleClient; - -internal class Program -{ - private const string HubBaseUrl = "https://govor-team-govor-88b3.twc1.net/hubs"; - private static string jwtToken = "eyJhbGciOiJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGRzaWctbW9yZSNobWFjLXNoYTI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI4NDRjNzkyMi1hNjdlLTRlMzctYWI0MC0wNThlZDE5NzM5NjMiLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3JvbGUiOiJBZG1pbiIsImV4cCI6MTc1MjQ4MzE0MH0.ECXLPwIljdeWgWUJtBiXXQXKMFC8Iw0x7_zdjbsUe1I"; // сюда подставьте валидный JWT - - static async Task Main() - { - Console.Write("🔑 JWT Token: "); - jwtToken = Console.ReadLine(); - - var client = new FriendsHubClient(HubBaseUrl, jwtToken); - await client.ConnectAsync(); - - while (true) - { - Console.WriteLine("\n1. Send friend request"); - Console.WriteLine("2. Accept request"); - Console.WriteLine("3. Reject request"); - Console.Write("➡ Choose option: "); - var option = Console.ReadLine(); - - switch (option) - { - case "1": - Console.Write("Target UserId: "); - if (Guid.TryParse(Console.ReadLine(), out var targetId)) - { - await client.SendFriendRequestAsync(targetId); - } - break; - case "2": - Console.Write("FriendshipId to accept: "); - if (Guid.TryParse(Console.ReadLine(), out var acceptId)) - { - await client.AcceptFriendRequestAsync(acceptId); - } - break; - case "3": - Console.Write("FriendshipId to reject: "); - if (Guid.TryParse(Console.ReadLine(), out var rejectId)) - { - await client.RejectFriendRequestAsync(rejectId); - } - break; - default: - Console.WriteLine("❗ Unknown option"); - break; - } - } - } -} \ No newline at end of file diff --git a/Govor.ConsoleClient/App.cs b/Govor.ConsoleClient/App.cs new file mode 100644 index 0000000..bbdfa7a --- /dev/null +++ b/Govor.ConsoleClient/App.cs @@ -0,0 +1,27 @@ +using Govor.ConsoleClient.Services; + +namespace Govor.ConsoleClient; + +public class App +{ + private readonly InputPipeline _inputPipeline; + private readonly ILogger _logger; + + public App(InputPipeline inputPipeline, ILogger logger) + { + _logger = logger; + _inputPipeline = inputPipeline; + } + + public async Task RunAsync() + { + _logger.Title("Добро пожаловать в консольный клиент Говор!"); + while (true) + { + Console.Write(">> "); + var input = Console.ReadLine(); + if (input == null || input.Trim().ToLower() == "exit") break; + await _inputPipeline.ProcessInputAsync(input); + } + } +} diff --git a/Govor.ConsoleClient/Commands/HelpCommand.cs b/Govor.ConsoleClient/Commands/HelpCommand.cs new file mode 100644 index 0000000..b50c796 --- /dev/null +++ b/Govor.ConsoleClient/Commands/HelpCommand.cs @@ -0,0 +1,22 @@ +using Govor.ConsoleClient.Services; + +namespace Govor.ConsoleClient.Commands; + +public class HelpCommand : ICommand +{ + public Task ExecuteAsync(CommandContext context) + { + Console.WriteLine("Commands:"); + return Task.CompletedTask; + } + + public string LongHelp() + { + throw new NotImplementedException(); + } + + public string ShortHelp() + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/Govor.ConsoleClient/Commands/ICommand.cs b/Govor.ConsoleClient/Commands/ICommand.cs new file mode 100644 index 0000000..aa1c844 --- /dev/null +++ b/Govor.ConsoleClient/Commands/ICommand.cs @@ -0,0 +1,17 @@ +using Govor.ConsoleClient.Services; + +namespace Govor.ConsoleClient.Commands; + +public interface ICommand +{ + Task ExecuteAsync(CommandContext context); + string LongHelp(); + string ShortHelp(); +} + +[AttributeUsage(AttributeTargets.Class)] +public class CommandRouteAttribute : Attribute +{ + public string Path { get; } + public CommandRouteAttribute(string path) => Path = path; +} \ No newline at end of file diff --git a/Govor.ConsoleClient/Commands/IInteractiveCommand.cs b/Govor.ConsoleClient/Commands/IInteractiveCommand.cs new file mode 100644 index 0000000..c13154e --- /dev/null +++ b/Govor.ConsoleClient/Commands/IInteractiveCommand.cs @@ -0,0 +1,7 @@ +namespace Govor.ConsoleClient.Commands; + +public interface IInteractiveCommand : ICommand +{ + Task HandleInputAsync(string input); + bool IsCompleted { get; } +} \ No newline at end of file diff --git a/Govor.ConsoleClient/Commands/SendMessageCommand.cs b/Govor.ConsoleClient/Commands/SendMessageCommand.cs new file mode 100644 index 0000000..544f264 --- /dev/null +++ b/Govor.ConsoleClient/Commands/SendMessageCommand.cs @@ -0,0 +1,44 @@ +using Govor.ConsoleClient.Services; + +namespace Govor.ConsoleClient.Commands; + +[CommandRoute("/send")] +public class SendMessageCommand : IInteractiveCommand +{ + private string? _recipient; + private bool _isCompleted; + public bool IsCompleted => _isCompleted; + + public Task ExecuteAsync(CommandContext context) + { + Console.WriteLine("Кому вы хотите отправить сообщение?"); + return Task.CompletedTask; + } + + public async Task HandleInputAsync(string input) + { + if (_recipient == null) + { + _recipient = input; + Console.WriteLine("Введите сообщение:"); + } + else + { + var message = input; + Console.WriteLine($"(Отправка '{message}' пользователю '{_recipient}')"); + _isCompleted = true; + } + + await Task.CompletedTask; + } + + public string LongHelp() + { + throw new NotImplementedException(); + } + + public string ShortHelp() + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/Govor.ConsoleClient/DependencyInjection.cs b/Govor.ConsoleClient/DependencyInjection.cs new file mode 100644 index 0000000..ff33799 --- /dev/null +++ b/Govor.ConsoleClient/DependencyInjection.cs @@ -0,0 +1,47 @@ +using System.Reflection; +using Govor.ConsoleClient.Commands; +using Govor.ConsoleClient.Services; +using Govor.ConsoleClient.Services.Middleware; +using Microsoft.Extensions.DependencyInjection; + +namespace Govor.ConsoleClient; + +public static class DependencyInjection +{ + public static IServiceProvider Configure() + { + var services = new ServiceCollection(); + + // Регистрация команд + services.AddCommands(); + + // Сервисы + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); + + // Middleware + services.AddSingleton(); + + + return services.BuildServiceProvider(); + } + + public static IServiceCollection AddCommands(this IServiceCollection services) + { + var commandTypes = Assembly.GetExecutingAssembly() + .GetTypes() + .Where(t => typeof(ICommand).IsAssignableFrom(t) && !t.IsInterface && !t.IsAbstract); + + foreach (var type in commandTypes) + { + services.AddTransient(typeof(ICommand), type); + services.AddTransient(type); // Для прямого внедрения + } + + return services; + } +} \ No newline at end of file diff --git a/Govor.Console/Govor.Console.csproj b/Govor.ConsoleClient/Govor.ConsoleClient.csproj similarity index 81% rename from Govor.Console/Govor.Console.csproj rename to Govor.ConsoleClient/Govor.ConsoleClient.csproj index 0f9919f..e3de400 100644 --- a/Govor.Console/Govor.Console.csproj +++ b/Govor.ConsoleClient/Govor.ConsoleClient.csproj @@ -19,5 +19,12 @@ + + + + + + + diff --git a/Govor.ConsoleClient/Program.cs b/Govor.ConsoleClient/Program.cs new file mode 100644 index 0000000..491b499 --- /dev/null +++ b/Govor.ConsoleClient/Program.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Govor.ConsoleClient; + +internal class Program +{ + //private const string HubBaseUrl = "https://govor-team-govor-88b3.twc1.net/hubs"; + + static async Task Main() + { + Console.Title = "Govor Console Client"; + + var serviceProvider = DependencyInjection.Configure(); + + var app = ActivatorUtilities.CreateInstance(serviceProvider); + await app.RunAsync(); + } +} \ No newline at end of file diff --git a/Govor.ConsoleClient/Services/CommandContext.cs b/Govor.ConsoleClient/Services/CommandContext.cs new file mode 100644 index 0000000..d3af695 --- /dev/null +++ b/Govor.ConsoleClient/Services/CommandContext.cs @@ -0,0 +1,17 @@ +using Govor.ConsoleClient.Commands; + +namespace Govor.ConsoleClient.Services; + +public class CommandContext +{ + public string Route { get; } + public string? Arguments { get; } + public ICommand Command { get; } + + public CommandContext(string route, string? arguments, ICommand command) + { + Route = route; + Arguments = arguments; + Command = command; + } +} \ No newline at end of file diff --git a/Govor.ConsoleClient/Services/CommandDispatcher.cs b/Govor.ConsoleClient/Services/CommandDispatcher.cs new file mode 100644 index 0000000..ae0a299 --- /dev/null +++ b/Govor.ConsoleClient/Services/CommandDispatcher.cs @@ -0,0 +1,41 @@ +using System.Reflection; +using Govor.ConsoleClient.Commands; + +namespace Govor.ConsoleClient.Services; + +public class CommandDispatcher +{ + private readonly Dictionary _commands = new(); + private readonly ILogger _logger; + private readonly MiddlewarePipeline _pipeline; + + public CommandDispatcher(IEnumerable commands, ILogger logger, MiddlewarePipeline pipeline) + { + _logger = logger; + _pipeline = pipeline; + + foreach (var command in commands) + { + var route = command.GetType().GetCustomAttribute()?.Path.Replace("/","") + ?? command.GetType().Name.Replace("Command", "").ToLower(); + _commands[route.ToLower()] = command; + } + } + + public async Task DispatchAsync(string input) + { + var args = input.Split(' ', 2); + var cmd = args[0].ToLower(); + if (_commands.TryGetValue(cmd, out var command)) + { + var context = new CommandContext(cmd, args.Length > 1 ? args[1] : null, command); + await _pipeline.ExecuteAsync(context); + return command; + } + else + { + _logger.Warn("Неизвестная команда. Введите '/help'."); + return null; + } + } +} \ No newline at end of file diff --git a/Govor.ConsoleClient/Services/ConsoleLogger.cs b/Govor.ConsoleClient/Services/ConsoleLogger.cs new file mode 100644 index 0000000..7e2b0b8 --- /dev/null +++ b/Govor.ConsoleClient/Services/ConsoleLogger.cs @@ -0,0 +1,45 @@ +namespace Govor.ConsoleClient.Services; + +public class ConsoleLogger : ILogger +{ + public void Log(string message) + { + Console.ResetColor(); + Console.WriteLine(message); + } + + public void Info(string message) + { + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine($"[INFO] {message}"); + Console.ResetColor(); + } + + public void Warn(string message) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"[WARN] {message}"); + Console.ResetColor(); + } + + public void Error(string message) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"[ERROR] {message}"); + Console.ResetColor(); + } + + public void Title(string message) + { + var upper = message.ToUpper(); + var length = upper.Length + 6; + var border = new string('=', length); + var padded = $"= {upper} ="; + + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine(border); + Console.WriteLine(padded); + Console.WriteLine(border); + Console.ResetColor(); + } +} \ No newline at end of file diff --git a/Govor.ConsoleClient/Services/ILogger.cs b/Govor.ConsoleClient/Services/ILogger.cs new file mode 100644 index 0000000..9e27a75 --- /dev/null +++ b/Govor.ConsoleClient/Services/ILogger.cs @@ -0,0 +1,10 @@ +namespace Govor.ConsoleClient.Services; + +public interface ILogger +{ + void Log(string message); + void Info(string message); + void Warn(string message); + void Error(string message); + void Title(string title); +} \ No newline at end of file diff --git a/Govor.ConsoleClient/Services/InputPipeline.cs b/Govor.ConsoleClient/Services/InputPipeline.cs new file mode 100644 index 0000000..ed93d15 --- /dev/null +++ b/Govor.ConsoleClient/Services/InputPipeline.cs @@ -0,0 +1,49 @@ +using Govor.ConsoleClient.Commands; + +namespace Govor.ConsoleClient.Services; + +public class InputPipeline +{ + private readonly CommandDispatcher _dispatcher; + private readonly ILogger _logger; + private IInteractiveCommand? _activeCommand; + + public InputPipeline(CommandDispatcher dispatcher, ILogger logger) + { + _dispatcher = dispatcher; + _logger = logger; + } + + public async Task ProcessInputAsync(string input) + { + if (string.IsNullOrWhiteSpace(input)) return; + + if (input.StartsWith("/")) + { + _activeCommand = null; // Сброс активной команды + + var commandInput = input[1..]; + var result = await _dispatcher.DispatchAsync(commandInput); + + // Если команда поддерживает интерактивность, сохраняем как активную + if (result is IInteractiveCommand interactiveCommand && !interactiveCommand.IsCompleted) + { + _activeCommand = interactiveCommand; + } + } + else + { + if (_activeCommand != null) + { + await _activeCommand.HandleInputAsync(input); + + if (_activeCommand.IsCompleted) + _activeCommand = null; + } + else + { + _logger.Info($"[Ввод пользователя]: {input}"); + } + } + } +} \ No newline at end of file diff --git a/Govor.ConsoleClient/Services/Middleware/ExceptionHandlingMiddleware.cs b/Govor.ConsoleClient/Services/Middleware/ExceptionHandlingMiddleware.cs new file mode 100644 index 0000000..7cd6fee --- /dev/null +++ b/Govor.ConsoleClient/Services/Middleware/ExceptionHandlingMiddleware.cs @@ -0,0 +1,23 @@ +namespace Govor.ConsoleClient.Services.Middleware; + +public class ExceptionHandlingMiddleware : ICommandMiddleware +{ + private readonly ILogger _logger; + + public ExceptionHandlingMiddleware(ILogger logger) + { + _logger = logger; + } + + public async Task InvokeAsync(CommandContext context, Func next) + { + try + { + await next(); + } + catch (Exception ex) + { + _logger.Error($"Произошла ошибка при выполнении команды '{context.Route}': {ex.Message}"); + } + } +} \ No newline at end of file diff --git a/Govor.ConsoleClient/Services/Middleware/ICommandMiddleware.cs b/Govor.ConsoleClient/Services/Middleware/ICommandMiddleware.cs new file mode 100644 index 0000000..e441906 --- /dev/null +++ b/Govor.ConsoleClient/Services/Middleware/ICommandMiddleware.cs @@ -0,0 +1,6 @@ +namespace Govor.ConsoleClient.Services.Middleware; + +public interface ICommandMiddleware +{ + Task InvokeAsync(CommandContext context, Func next); +} \ No newline at end of file diff --git a/Govor.ConsoleClient/Services/MiddlewarePipeline.cs b/Govor.ConsoleClient/Services/MiddlewarePipeline.cs new file mode 100644 index 0000000..e19dd23 --- /dev/null +++ b/Govor.ConsoleClient/Services/MiddlewarePipeline.cs @@ -0,0 +1,29 @@ +using Govor.ConsoleClient.Services.Middleware; + +namespace Govor.ConsoleClient.Services; + +public delegate Task CommandMiddleware(CommandContext context, Func next); + +public class MiddlewarePipeline +{ + private readonly IList _middlewares; + + public MiddlewarePipeline(IEnumerable middlewares) + { + _middlewares = middlewares.ToList(); + } + + public Task ExecuteAsync(CommandContext context) + { + return InvokeNext(0, context); + } + + private Task InvokeNext(int index, CommandContext context) + { + if (index < _middlewares.Count) + { + return _middlewares[index].InvokeAsync(context, () => InvokeNext(index + 1, context)); + } + return context.Command.ExecuteAsync(context); + } +} \ No newline at end of file diff --git a/Govor.sln b/Govor.sln index 0aec0ab..99567f9 100644 --- a/Govor.sln +++ b/Govor.sln @@ -8,7 +8,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Govor.Core", "Govor.Core\Go EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Govor.Data", "Govor.Data\Govor.Data.csproj", "{E4EDB179-7EB5-468D-9C1F-0CBE2E5E459E}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Govor.Console", "Govor.Console\Govor.Console.csproj", "{F4535DC3-BDFB-4EB2-B259-F92B6BBB535B}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Govor.ConsoleClient", "Govor.ConsoleClient\Govor.ConsoleClient.csproj", "{F4535DC3-BDFB-4EB2-B259-F92B6BBB535B}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{4ED5259A-6FB4-4D89-8E6B-4778DC68F7D4}" EndProject From 272809d1d816a29454d17278253b5f796bbd959d Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Sat, 19 Jul 2025 23:18:08 +0700 Subject: [PATCH 051/101] AppTests --- Govor.ConsoleClient.Tests/AppTests.cs | 59 +++++++++ .../Govor.ConsoleClient.Tests.csproj | 29 ++++ .../Implementations/InputPipelineTests.cs | 124 ++++++++++++++++++ Govor.ConsoleClient/App.cs | 5 +- Govor.ConsoleClient/Commands/HelpCommand.cs | 56 ++++++-- .../Commands/SendMessageCommand.cs | 2 +- Govor.ConsoleClient/DependencyInjection.cs | 9 +- .../Extensions/ServiceCollectionExtensions.cs | 18 +++ .../CommandDispatcher.cs | 11 +- .../{ => Implementations}/ConsoleLogger.cs | 4 +- .../{ => Implementations}/InputPipeline.cs | 15 ++- .../MiddlewarePipeline.cs | 5 +- .../Services/Interfaces/ICommandDispatcher.cs | 9 ++ .../Services/Interfaces/IInputPipeline.cs | 6 + .../Services/{ => Interfaces}/ILogger.cs | 2 +- .../Interfaces/IMiddlewarePipeline.cs | 6 + .../Middleware/ExceptionHandlingMiddleware.cs | 4 +- Govor.ConsoleClient/appsettings.json | 3 + Govor.sln | 7 + 19 files changed, 340 insertions(+), 34 deletions(-) create mode 100644 Govor.ConsoleClient.Tests/AppTests.cs create mode 100644 Govor.ConsoleClient.Tests/Govor.ConsoleClient.Tests.csproj create mode 100644 Govor.ConsoleClient.Tests/Services/Implementations/InputPipelineTests.cs create mode 100644 Govor.ConsoleClient/Services/Extensions/ServiceCollectionExtensions.cs rename Govor.ConsoleClient/Services/{ => Implementations}/CommandDispatcher.cs (78%) rename Govor.ConsoleClient/Services/{ => Implementations}/ConsoleLogger.cs (91%) rename Govor.ConsoleClient/Services/{ => Implementations}/InputPipeline.cs (66%) rename Govor.ConsoleClient/Services/{ => Implementations}/MiddlewarePipeline.cs (82%) create mode 100644 Govor.ConsoleClient/Services/Interfaces/ICommandDispatcher.cs create mode 100644 Govor.ConsoleClient/Services/Interfaces/IInputPipeline.cs rename Govor.ConsoleClient/Services/{ => Interfaces}/ILogger.cs (77%) create mode 100644 Govor.ConsoleClient/Services/Interfaces/IMiddlewarePipeline.cs create mode 100644 Govor.ConsoleClient/appsettings.json diff --git a/Govor.ConsoleClient.Tests/AppTests.cs b/Govor.ConsoleClient.Tests/AppTests.cs new file mode 100644 index 0000000..2d99f0a --- /dev/null +++ b/Govor.ConsoleClient.Tests/AppTests.cs @@ -0,0 +1,59 @@ +using Govor.ConsoleClient.Services.Interfaces; +using Moq; + +namespace Govor.ConsoleClient.Tests; + +[TestFixture] +public class AppTests +{ + private Mock _inputPipelineMock = null!; + private Mock _loggerMock = null!; + private App _app = null!; + + [SetUp] + public void Setup() + { + _inputPipelineMock = new Mock(); + _loggerMock = new Mock(); + + _inputPipelineMock.Setup(f => f.ProcessInputAsync(It.IsAny())).Returns(Task.CompletedTask); + + _app = new App(_inputPipelineMock.Object, _loggerMock.Object); + } + + [Test] + public async Task RunAsync_PrintsTitleOnce() + { + using var sr = new StringReader("exit"); + Console.SetIn(sr); + + await _app.RunAsync(); + + _loggerMock.Verify(l => l.Title(It.Is(s => s.Contains("Говор"))), Times.Once); + } + + [Test] + public async Task RunAsync_CallsInputPipeline_ForEachInput() + { + var inputText = "hello\n/command\nexit"; + using var sr = new StringReader(inputText); + Console.SetIn(sr); + + await _app.RunAsync(); + + _inputPipelineMock.Verify(p => p.ProcessInputAsync("hello"), Times.Once); + _inputPipelineMock.Verify(p => p.ProcessInputAsync("/command"), Times.Once); + } + + [Test] + public async Task RunAsync_StopsOnExit() + { + using var sr = new StringReader("exit"); + Console.SetIn(sr); + + await _app.RunAsync(); + + // Убедимся, что ProcessInputAsync вообще не вызывался + _inputPipelineMock.Verify(p => p.ProcessInputAsync(It.IsAny()), Times.Never); + } +} \ No newline at end of file diff --git a/Govor.ConsoleClient.Tests/Govor.ConsoleClient.Tests.csproj b/Govor.ConsoleClient.Tests/Govor.ConsoleClient.Tests.csproj new file mode 100644 index 0000000..e50bf85 --- /dev/null +++ b/Govor.ConsoleClient.Tests/Govor.ConsoleClient.Tests.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + diff --git a/Govor.ConsoleClient.Tests/Services/Implementations/InputPipelineTests.cs b/Govor.ConsoleClient.Tests/Services/Implementations/InputPipelineTests.cs new file mode 100644 index 0000000..28ef54d --- /dev/null +++ b/Govor.ConsoleClient.Tests/Services/Implementations/InputPipelineTests.cs @@ -0,0 +1,124 @@ +using Govor.ConsoleClient.Commands; +using Govor.ConsoleClient.Services.Implementations; +using Govor.ConsoleClient.Services.Interfaces; +using Moq; +using NUnit.Framework; +using System.Threading.Tasks; + +namespace Govor.ConsoleClient.Tests.Services.Implementations; + +[TestFixture] +public class InputPipelineTests +{ + private Mock _commandDispatcherMock = null!; + private Mock _loggerMock = null!; + private InputPipeline _inputPipeline = null!; + + [SetUp] + public void SetUp() + { + _commandDispatcherMock = new Mock(); + _loggerMock = new Mock(); + + _inputPipeline = new InputPipeline(_commandDispatcherMock.Object, _loggerMock.Object); + } + + [Test] + public async Task ProcessInputAsync_EmptyOrWhitespaceInput_DoesNothing() + { + // Act + await _inputPipeline.ProcessInputAsync(null!); + await _inputPipeline.ProcessInputAsync(""); + await _inputPipeline.ProcessInputAsync(" "); + + // Assert + _commandDispatcherMock.Verify(d => d.DispatchAsync(It.IsAny()), Times.Never); + _loggerMock.Verify(l => l.Info(It.IsAny()), Times.Never); + } + + [Test] + public async Task ProcessInputAsync_CommandInput_DispatchesCommandAndResetsActiveCommand() + { + // Arrange + var commandMock = new Mock(); + _commandDispatcherMock + .Setup(d => d.DispatchAsync("testcmd")) + .ReturnsAsync(commandMock.Object); + + // Act + await _inputPipeline.ProcessInputAsync("/testcmd"); + + // Assert + _commandDispatcherMock.Verify(d => d.DispatchAsync("testcmd"), Times.Once); + _loggerMock.Verify(l => l.Info(It.IsAny()), Times.Never); + } + + [Test] + public async Task ProcessInputAsync_CommandInput_IfInteractiveCommand_SetsActiveCommand() + { + // Arrange + var interactiveCommandMock = new Mock(); + interactiveCommandMock.SetupGet(c => c.IsCompleted).Returns(false); + _commandDispatcherMock + .Setup(d => d.DispatchAsync("interactive")) + .ReturnsAsync(interactiveCommandMock.Object); + + // Act + await _inputPipeline.ProcessInputAsync("/interactive"); + + // Assert + var activeCommandField = typeof(InputPipeline).GetField("_activeCommand", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var activeCommandValue = activeCommandField!.GetValue(_inputPipeline); + Assert.That(activeCommandValue, Is.SameAs(interactiveCommandMock.Object)); + } + + [Test] + public async Task ProcessInputAsync_NonCommandInput_WithActiveInteractiveCommand_CallsHandleInputAsync() + { + // Arrange + var interactiveCommandMock = new Mock(); + interactiveCommandMock.SetupGet(c => c.IsCompleted).Returns(false); + interactiveCommandMock.Setup(c => c.HandleInputAsync("user input")).Returns(Task.CompletedTask); + + var activeCommandField = typeof(InputPipeline).GetField("_activeCommand", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + activeCommandField!.SetValue(_inputPipeline, interactiveCommandMock.Object); + + // Act + await _inputPipeline.ProcessInputAsync("user input"); + + // Assert + interactiveCommandMock.Verify(c => c.HandleInputAsync("user input"), Times.Once); + } + + [Test] + public async Task ProcessInputAsync_NonCommandInput_WithActiveInteractiveCommand_ResetsActiveCommand_WhenCompleted() + { + // Arrange + var interactiveCommandMock = new Mock(); + interactiveCommandMock.SetupSequence(c => c.IsCompleted) + .Returns(false) + .Returns(true); + interactiveCommandMock.Setup(c => c.HandleInputAsync(It.IsAny())).Returns(Task.CompletedTask); + + var activeCommandField = typeof(InputPipeline).GetField("_activeCommand", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + activeCommandField!.SetValue(_inputPipeline, interactiveCommandMock.Object); + + // Act & Assert 1 + await _inputPipeline.ProcessInputAsync("input1"); + Assert.That(interactiveCommandMock.Object, Is.SameAs(activeCommandField.GetValue(_inputPipeline))); + + // Act & Assert 2 + await _inputPipeline.ProcessInputAsync("input2"); + Assert.That(activeCommandField.GetValue(_inputPipeline), Is.Null); + } + + [Test] + public async Task ProcessInputAsync_NonCommandInput_WithoutActiveCommand_LogsInfo() + { + // Act + await _inputPipeline.ProcessInputAsync("just text"); + + // Assert + _loggerMock.Verify(l => l.Info("Введите /help, чтобы узнать доступные команды!"), Times.Once); + } +} diff --git a/Govor.ConsoleClient/App.cs b/Govor.ConsoleClient/App.cs index bbdfa7a..8b2a59a 100644 --- a/Govor.ConsoleClient/App.cs +++ b/Govor.ConsoleClient/App.cs @@ -1,13 +1,14 @@ using Govor.ConsoleClient.Services; +using Govor.ConsoleClient.Services.Interfaces; namespace Govor.ConsoleClient; public class App { - private readonly InputPipeline _inputPipeline; + private readonly IInputPipeline _inputPipeline; private readonly ILogger _logger; - public App(InputPipeline inputPipeline, ILogger logger) + public App(IInputPipeline inputPipeline, ILogger logger) { _logger = logger; _inputPipeline = inputPipeline; diff --git a/Govor.ConsoleClient/Commands/HelpCommand.cs b/Govor.ConsoleClient/Commands/HelpCommand.cs index b50c796..b8cd16f 100644 --- a/Govor.ConsoleClient/Commands/HelpCommand.cs +++ b/Govor.ConsoleClient/Commands/HelpCommand.cs @@ -1,22 +1,58 @@ +using System.Reflection; using Govor.ConsoleClient.Services; +using Govor.ConsoleClient.Services.Interfaces; +using Microsoft.Extensions.DependencyInjection; namespace Govor.ConsoleClient.Commands; public class HelpCommand : ICommand { + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public HelpCommand(IServiceProvider serviceProvider, ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + public Task ExecuteAsync(CommandContext context) { - Console.WriteLine("Commands:"); + var dispatcher = _serviceProvider.GetRequiredService(); + var commands = dispatcher.GetAllCommands(); + + if (context.Arguments is not null) + { + var command = commands.FirstOrDefault(c => + (c.GetType().GetCustomAttribute()?.Path.Replace("/", "").ToLower() + ?? c.GetType().Name.Replace("Command", "").ToLower()) == context.Arguments.ToLower()); + + if (command != null) + { + _logger.Info($"{context.Arguments} - {command.LongHelp()}"); + } + else + { + _logger.Warn("Unknown command"); + } + } + else + { + _logger.Info("Чтобы получить подробную информацию, напишите /help {command}"); + } + + foreach (var command in commands) + { + var name = command.GetType().GetCustomAttribute()?.Path.Replace("/", "").ToLower() + ?? command.GetType().Name.Replace("Command", "").ToLower(); + + _logger.Log($"{name} - {command.ShortHelp()}"); + } + return Task.CompletedTask; } - public string LongHelp() - { - throw new NotImplementedException(); - } + public string LongHelp() => "Необходима для получения информации о доступных командах\nЧтобы получить подробную информацию о команде, напишите /help {command}"; - public string ShortHelp() - { - throw new NotImplementedException(); - } -} \ No newline at end of file + public string ShortHelp() => "Необходима для получения информации о доступных командах"; +} diff --git a/Govor.ConsoleClient/Commands/SendMessageCommand.cs b/Govor.ConsoleClient/Commands/SendMessageCommand.cs index 544f264..8aa96db 100644 --- a/Govor.ConsoleClient/Commands/SendMessageCommand.cs +++ b/Govor.ConsoleClient/Commands/SendMessageCommand.cs @@ -39,6 +39,6 @@ public class SendMessageCommand : IInteractiveCommand public string ShortHelp() { - throw new NotImplementedException(); + return "Отпарвка тестовых сообщений не существующему юзеру"; } } \ No newline at end of file diff --git a/Govor.ConsoleClient/DependencyInjection.cs b/Govor.ConsoleClient/DependencyInjection.cs index ff33799..15f06f5 100644 --- a/Govor.ConsoleClient/DependencyInjection.cs +++ b/Govor.ConsoleClient/DependencyInjection.cs @@ -1,6 +1,8 @@ using System.Reflection; using Govor.ConsoleClient.Commands; using Govor.ConsoleClient.Services; +using Govor.ConsoleClient.Services.Extensions; +using Govor.ConsoleClient.Services.Interfaces; using Govor.ConsoleClient.Services.Middleware; using Microsoft.Extensions.DependencyInjection; @@ -16,12 +18,9 @@ public static class DependencyInjection services.AddCommands(); // Сервисы - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(sp => sp.GetRequiredService()); + services.AddApplicationServices(); + services.AddSingleton(); - services.AddSingleton(); // Middleware services.AddSingleton(); diff --git a/Govor.ConsoleClient/Services/Extensions/ServiceCollectionExtensions.cs b/Govor.ConsoleClient/Services/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..a095ff0 --- /dev/null +++ b/Govor.ConsoleClient/Services/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,18 @@ +using Govor.ConsoleClient.Services.Implementations; +using Govor.ConsoleClient.Services.Interfaces; +using Microsoft.Extensions.DependencyInjection; + +namespace Govor.ConsoleClient.Services.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddApplicationServices(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + return services; + } +} \ No newline at end of file diff --git a/Govor.ConsoleClient/Services/CommandDispatcher.cs b/Govor.ConsoleClient/Services/Implementations/CommandDispatcher.cs similarity index 78% rename from Govor.ConsoleClient/Services/CommandDispatcher.cs rename to Govor.ConsoleClient/Services/Implementations/CommandDispatcher.cs index ae0a299..52b440d 100644 --- a/Govor.ConsoleClient/Services/CommandDispatcher.cs +++ b/Govor.ConsoleClient/Services/Implementations/CommandDispatcher.cs @@ -1,15 +1,16 @@ using System.Reflection; using Govor.ConsoleClient.Commands; +using Govor.ConsoleClient.Services.Interfaces; -namespace Govor.ConsoleClient.Services; +namespace Govor.ConsoleClient.Services.Implementations; -public class CommandDispatcher +public class CommandDispatcher : ICommandDispatcher { private readonly Dictionary _commands = new(); private readonly ILogger _logger; - private readonly MiddlewarePipeline _pipeline; + private readonly IMiddlewarePipeline _pipeline; - public CommandDispatcher(IEnumerable commands, ILogger logger, MiddlewarePipeline pipeline) + public CommandDispatcher(IEnumerable commands, ILogger logger, IMiddlewarePipeline pipeline) { _logger = logger; _pipeline = pipeline; @@ -38,4 +39,6 @@ public class CommandDispatcher return null; } } + + public IEnumerable GetAllCommands() => _commands.Values; } \ No newline at end of file diff --git a/Govor.ConsoleClient/Services/ConsoleLogger.cs b/Govor.ConsoleClient/Services/Implementations/ConsoleLogger.cs similarity index 91% rename from Govor.ConsoleClient/Services/ConsoleLogger.cs rename to Govor.ConsoleClient/Services/Implementations/ConsoleLogger.cs index 7e2b0b8..86d22c3 100644 --- a/Govor.ConsoleClient/Services/ConsoleLogger.cs +++ b/Govor.ConsoleClient/Services/Implementations/ConsoleLogger.cs @@ -1,4 +1,6 @@ -namespace Govor.ConsoleClient.Services; +using Govor.ConsoleClient.Services.Interfaces; + +namespace Govor.ConsoleClient.Services.Implementations; public class ConsoleLogger : ILogger { diff --git a/Govor.ConsoleClient/Services/InputPipeline.cs b/Govor.ConsoleClient/Services/Implementations/InputPipeline.cs similarity index 66% rename from Govor.ConsoleClient/Services/InputPipeline.cs rename to Govor.ConsoleClient/Services/Implementations/InputPipeline.cs index ed93d15..996fbe6 100644 --- a/Govor.ConsoleClient/Services/InputPipeline.cs +++ b/Govor.ConsoleClient/Services/Implementations/InputPipeline.cs @@ -1,14 +1,15 @@ using Govor.ConsoleClient.Commands; +using Govor.ConsoleClient.Services.Interfaces; -namespace Govor.ConsoleClient.Services; +namespace Govor.ConsoleClient.Services.Implementations; -public class InputPipeline +public class InputPipeline : IInputPipeline { - private readonly CommandDispatcher _dispatcher; + private readonly ICommandDispatcher _dispatcher; private readonly ILogger _logger; private IInteractiveCommand? _activeCommand; - public InputPipeline(CommandDispatcher dispatcher, ILogger logger) + public InputPipeline(ICommandDispatcher dispatcher, ILogger logger) { _dispatcher = dispatcher; _logger = logger; @@ -20,12 +21,12 @@ public class InputPipeline if (input.StartsWith("/")) { - _activeCommand = null; // Сброс активной команды + _activeCommand = null; var commandInput = input[1..]; var result = await _dispatcher.DispatchAsync(commandInput); - // Если команда поддерживает интерактивность, сохраняем как активную + // If the command supports interactivity, save it as active if (result is IInteractiveCommand interactiveCommand && !interactiveCommand.IsCompleted) { _activeCommand = interactiveCommand; @@ -42,7 +43,7 @@ public class InputPipeline } else { - _logger.Info($"[Ввод пользователя]: {input}"); + _logger.Info($"Введите /help, чтобы узнать доступные команды!"); } } } diff --git a/Govor.ConsoleClient/Services/MiddlewarePipeline.cs b/Govor.ConsoleClient/Services/Implementations/MiddlewarePipeline.cs similarity index 82% rename from Govor.ConsoleClient/Services/MiddlewarePipeline.cs rename to Govor.ConsoleClient/Services/Implementations/MiddlewarePipeline.cs index e19dd23..4cc7da1 100644 --- a/Govor.ConsoleClient/Services/MiddlewarePipeline.cs +++ b/Govor.ConsoleClient/Services/Implementations/MiddlewarePipeline.cs @@ -1,10 +1,11 @@ +using Govor.ConsoleClient.Services.Interfaces; using Govor.ConsoleClient.Services.Middleware; -namespace Govor.ConsoleClient.Services; +namespace Govor.ConsoleClient.Services.Implementations; public delegate Task CommandMiddleware(CommandContext context, Func next); -public class MiddlewarePipeline +public class MiddlewarePipeline : IMiddlewarePipeline { private readonly IList _middlewares; diff --git a/Govor.ConsoleClient/Services/Interfaces/ICommandDispatcher.cs b/Govor.ConsoleClient/Services/Interfaces/ICommandDispatcher.cs new file mode 100644 index 0000000..370adab --- /dev/null +++ b/Govor.ConsoleClient/Services/Interfaces/ICommandDispatcher.cs @@ -0,0 +1,9 @@ +using Govor.ConsoleClient.Commands; + +namespace Govor.ConsoleClient.Services.Interfaces; + +public interface ICommandDispatcher +{ + Task DispatchAsync(string input); + IEnumerable GetAllCommands(); +} diff --git a/Govor.ConsoleClient/Services/Interfaces/IInputPipeline.cs b/Govor.ConsoleClient/Services/Interfaces/IInputPipeline.cs new file mode 100644 index 0000000..be8fd92 --- /dev/null +++ b/Govor.ConsoleClient/Services/Interfaces/IInputPipeline.cs @@ -0,0 +1,6 @@ +namespace Govor.ConsoleClient.Services.Interfaces; + +public interface IInputPipeline +{ + Task ProcessInputAsync(string input); +} \ No newline at end of file diff --git a/Govor.ConsoleClient/Services/ILogger.cs b/Govor.ConsoleClient/Services/Interfaces/ILogger.cs similarity index 77% rename from Govor.ConsoleClient/Services/ILogger.cs rename to Govor.ConsoleClient/Services/Interfaces/ILogger.cs index 9e27a75..15136b0 100644 --- a/Govor.ConsoleClient/Services/ILogger.cs +++ b/Govor.ConsoleClient/Services/Interfaces/ILogger.cs @@ -1,4 +1,4 @@ -namespace Govor.ConsoleClient.Services; +namespace Govor.ConsoleClient.Services.Interfaces; public interface ILogger { diff --git a/Govor.ConsoleClient/Services/Interfaces/IMiddlewarePipeline.cs b/Govor.ConsoleClient/Services/Interfaces/IMiddlewarePipeline.cs new file mode 100644 index 0000000..b33e96a --- /dev/null +++ b/Govor.ConsoleClient/Services/Interfaces/IMiddlewarePipeline.cs @@ -0,0 +1,6 @@ +namespace Govor.ConsoleClient.Services.Interfaces; + +public interface IMiddlewarePipeline +{ + Task ExecuteAsync(CommandContext context); +} \ No newline at end of file diff --git a/Govor.ConsoleClient/Services/Middleware/ExceptionHandlingMiddleware.cs b/Govor.ConsoleClient/Services/Middleware/ExceptionHandlingMiddleware.cs index 7cd6fee..b105e77 100644 --- a/Govor.ConsoleClient/Services/Middleware/ExceptionHandlingMiddleware.cs +++ b/Govor.ConsoleClient/Services/Middleware/ExceptionHandlingMiddleware.cs @@ -1,3 +1,5 @@ +using Govor.ConsoleClient.Services.Interfaces; + namespace Govor.ConsoleClient.Services.Middleware; public class ExceptionHandlingMiddleware : ICommandMiddleware @@ -17,7 +19,7 @@ public class ExceptionHandlingMiddleware : ICommandMiddleware } catch (Exception ex) { - _logger.Error($"Произошла ошибка при выполнении команды '{context.Route}': {ex.Message}"); + _logger.Error($"Произошла ошибка при выполнении команды '{context?.Route}': {ex.Message}"); } } } \ No newline at end of file diff --git a/Govor.ConsoleClient/appsettings.json b/Govor.ConsoleClient/appsettings.json new file mode 100644 index 0000000..3cc9e3f --- /dev/null +++ b/Govor.ConsoleClient/appsettings.json @@ -0,0 +1,3 @@ +{ + "BaseUrl": "https://govor-team-govor-88b3.twc1.net" +} \ No newline at end of file diff --git a/Govor.sln b/Govor.sln index 99567f9..271c981 100644 --- a/Govor.sln +++ b/Govor.sln @@ -25,6 +25,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Govor.Application.Tests", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Govor.Data.Tests", "Govor.Data.Tests\Govor.Data.Tests.csproj", "{CF6B23EC-932A-4998-BA95-C94CAB7B092C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Govor.ConsoleClient.Tests", "Govor.ConsoleClient.Tests\Govor.ConsoleClient.Tests.csproj", "{B1E79EB6-DBD3-4E82-AB6D-DCFCE6533965}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -67,6 +69,10 @@ Global {CF6B23EC-932A-4998-BA95-C94CAB7B092C}.Debug|Any CPU.Build.0 = Debug|Any CPU {CF6B23EC-932A-4998-BA95-C94CAB7B092C}.Release|Any CPU.ActiveCfg = Release|Any CPU {CF6B23EC-932A-4998-BA95-C94CAB7B092C}.Release|Any CPU.Build.0 = Release|Any CPU + {B1E79EB6-DBD3-4E82-AB6D-DCFCE6533965}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B1E79EB6-DBD3-4E82-AB6D-DCFCE6533965}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B1E79EB6-DBD3-4E82-AB6D-DCFCE6533965}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B1E79EB6-DBD3-4E82-AB6D-DCFCE6533965}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {15031CBD-F319-4755-BA91-B86F20BD8E37} = {4ED5259A-6FB4-4D89-8E6B-4778DC68F7D4} @@ -78,5 +84,6 @@ Global {FC5EDCA8-FD58-4078-8FB1-2BDBB2F6CA3E} = {114F53C1-B0AB-4BA0-9E36-0E811D1B3776} {F56A64DF-2938-4BE0-83F2-B86429F19259} = {4ED5259A-6FB4-4D89-8E6B-4778DC68F7D4} {CF6B23EC-932A-4998-BA95-C94CAB7B092C} = {4ED5259A-6FB4-4D89-8E6B-4778DC68F7D4} + {B1E79EB6-DBD3-4E82-AB6D-DCFCE6533965} = {4ED5259A-6FB4-4D89-8E6B-4778DC68F7D4} EndGlobalSection EndGlobal From 50d53d1ea43de3a42bcf58fe4d2ec1c4e7b397b0 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Sat, 19 Jul 2025 23:55:49 +0700 Subject: [PATCH 052/101] Create MediaControllerTests.cs --- .../Controllers/MediaControllerTests.cs | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 Govor.API.Tests/IntegrationTests/Controllers/MediaControllerTests.cs diff --git a/Govor.API.Tests/IntegrationTests/Controllers/MediaControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/MediaControllerTests.cs new file mode 100644 index 0000000..e0175b6 --- /dev/null +++ b/Govor.API.Tests/IntegrationTests/Controllers/MediaControllerTests.cs @@ -0,0 +1,99 @@ +using AutoFixture; +using Govor.API.Controllers; +using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.Application.Interfaces.Medias; +using Govor.Contracts.Requests; +using Govor.Core.Models.Messages; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Govor.API.Tests.IntegrationTests.Controllers; + +[TestFixture] +public class MediaControllerTests +{ + private Fixture _fixture; + private Mock _currentUserMock; + private Mock> _loggerMock; + private Mock _mockMedia; + private MediaController _controller; + private Guid _userId = Guid.NewGuid(); + + [SetUp] + public void SetUp() + { + _fixture = new Fixture(); + _fixture.Behaviors.OfType().ToList().ForEach(b => _fixture.Behaviors.Remove(b)); + _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); + + _currentUserMock = new Mock(); + _loggerMock = new Mock>(); + _mockMedia = new Mock(); + + _currentUserMock.Setup(f => f.GetCurrentUserId()).Returns(_userId); + + _controller = new MediaController(_loggerMock.Object, _mockMedia.Object, _currentUserMock.Object); + } + + // Test for Upload action + [Test] + public async Task Upload_ValidRequest_ReturnsOkResult() + { + // Arrange + var content = "fake file content"; + var fileName = "testfile.txt"; + var fileBytes = System.Text.Encoding.UTF8.GetBytes(content); + var stream = new MemoryStream(fileBytes); + + var formFileMock = new Mock(); + formFileMock.Setup(f => f.Length).Returns(fileBytes.Length); + formFileMock.Setup(f => f.FileName).Returns(fileName); + formFileMock.Setup(f => f.OpenReadStream()).Returns(stream); + formFileMock.Setup(f => f.CopyToAsync(It.IsAny(), default)) + .Returns((target, _) => stream.CopyToAsync(target)); + + var uploadRequest = _fixture.Build() + .With(r => r.FromFile, formFileMock.Object) + .With(r => r.Type, MediaType.Image) + .With(r => r.MimeType, "image/png") + .With(r => r.EncryptedKey, "secret") + .Create(); + + var media = new Media( + _userId, + DateTime.UtcNow, + Path.GetFileName(uploadRequest.FromFile.FileName), + fileBytes, + uploadRequest.Type, + uploadRequest.MimeType, + uploadRequest.EncryptedKey + ); + + var uploadResult = _fixture.Create(); + + _mockMedia.Setup(f => f.UploadMediaAsync(It.IsAny())) + .ReturnsAsync(uploadResult); + + // Act + var result = await _controller.Upload(uploadRequest); + + // Assert + Assert.That(result, Is.InstanceOf()); + + var okResult = result as OkObjectResult; + var value = okResult?.Value as MediaUploadResult; + + Assert.That(value, Is.Not.Null); + Assert.That(value!.Url, Is.EqualTo(uploadResult.Url)); + Assert.That(value.MediaId, Is.EqualTo(uploadResult.MediaId)); + } + + + [TearDown] + public void TearDown() + { + _controller.Dispose(); + } +} \ No newline at end of file From 58e7716dedee6ebb66cafa2c4a867ec81866fe19 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Mon, 21 Jul 2025 13:42:41 +0700 Subject: [PATCH 053/101] MessagesLoaderTests --- .../Controllers/MediaControllerTests.cs | 10 -- .../Services/Messages/MessagesLoaderTests.cs | 168 ++++++++++++++++++ 2 files changed, 168 insertions(+), 10 deletions(-) create mode 100644 Govor.Application.Tests/Services/Messages/MessagesLoaderTests.cs diff --git a/Govor.API.Tests/IntegrationTests/Controllers/MediaControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/MediaControllerTests.cs index e0175b6..fcb5e4c 100644 --- a/Govor.API.Tests/IntegrationTests/Controllers/MediaControllerTests.cs +++ b/Govor.API.Tests/IntegrationTests/Controllers/MediaControllerTests.cs @@ -61,16 +61,6 @@ public class MediaControllerTests .With(r => r.EncryptedKey, "secret") .Create(); - var media = new Media( - _userId, - DateTime.UtcNow, - Path.GetFileName(uploadRequest.FromFile.FileName), - fileBytes, - uploadRequest.Type, - uploadRequest.MimeType, - uploadRequest.EncryptedKey - ); - var uploadResult = _fixture.Create(); _mockMedia.Setup(f => f.UploadMediaAsync(It.IsAny())) diff --git a/Govor.Application.Tests/Services/Messages/MessagesLoaderTests.cs b/Govor.Application.Tests/Services/Messages/MessagesLoaderTests.cs new file mode 100644 index 0000000..3c8f76d --- /dev/null +++ b/Govor.Application.Tests/Services/Messages/MessagesLoaderTests.cs @@ -0,0 +1,168 @@ +using Govor.Application.Interfaces; +using Govor.Application.Services.Messages; +using Govor.Core.Models; +using Govor.Core.Models.Messages; +using Govor.Core.Repositories.Groups; +using Govor.Core.Repositories.PrivateChats; +using Govor.Data; +using Microsoft.EntityFrameworkCore; +using Moq; + +namespace Govor.Application.Tests.Services.Messages; + +[TestFixture] +public class MessagesLoaderTests +{ + private IMessagesLoader _loader; + private Mock _privateChatsRepoMock; + private Mock _groupsRepoMock; + private GovorDbContext _dbContext; + + private Guid _currentUserId = Guid.NewGuid(); + private Guid _otherUserId = Guid.NewGuid(); + private Guid _groupChatId = Guid.NewGuid(); + + [SetUp] + public void Setup() + { + _privateChatsRepoMock = new Mock(); + _groupsRepoMock = new Mock(); + + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _dbContext = new GovorDbContext(options); + + _loader = new MessagesLoader(_groupsRepoMock.Object, _privateChatsRepoMock.Object, _dbContext); + } + + // Tests for LoadLastMessagesInUserChat action + [Test] + public async Task LoadLastMessagesInUserChat_ReturnsMessages_WhenChatExists() + { + // Arrange + var chatId = Guid.NewGuid(); + + _privateChatsRepoMock.Setup(r => r.Exist(_currentUserId, _otherUserId)).Returns(true); + _privateChatsRepoMock.Setup(r => r.GetByMembersAsync(_currentUserId, _otherUserId)) + .ReturnsAsync(new PrivateChat() { Id = chatId }); + + var messages = new List + { + new() { Id = Guid.NewGuid(), RecipientId = chatId, RecipientType = RecipientType.User } + }; + + await _dbContext.Messages.AddRangeAsync(messages); + await _dbContext.SaveChangesAsync(); + + // Act + var result = await _loader.LoadLastMessagesInUserChat(_currentUserId, _otherUserId, null); + + // Assert + Assert.That(result, Has.Count.EqualTo(1)); + } + + [Test] + public async Task LoadLastMessagesInUserChat_ReturnsEmpty_WhenNoMessages() + { + // Arrange + _privateChatsRepoMock.Setup(r => r.Exist(_currentUserId, _otherUserId)).Returns(true); + _privateChatsRepoMock.Setup(r => r.GetByMembersAsync(_currentUserId, _otherUserId)) + .ReturnsAsync(new PrivateChat { Id = Guid.NewGuid() }); + // Act + var result = await _loader.LoadLastMessagesInUserChat(_currentUserId, _otherUserId, null); + + // Assert + Assert.That(result, Is.Empty); + } + + [Test] + public void LoadLastMessagesInUserChat_Throws_WhenUserIdIsEmpty() + { + // Act & Assert + var ex = Assert.ThrowsAsync(async () => + await _loader.LoadLastMessagesInUserChat(Guid.Empty, _currentUserId, null)); + + Assert.That(ex.Message, Does.Contain("User id cannot be empty")); + } + + [Test] + public void LoadLastMessagesInUserChat_Throws_WhenChatNotExists() + { + // Arrange + _privateChatsRepoMock.Setup(r => r.Exist(It.IsAny(), It.IsAny())).Returns(false); + + // Act & Assert + var ex = Assert.ThrowsAsync(async () => + await _loader.LoadLastMessagesInUserChat(_currentUserId, _otherUserId, null)); + + Assert.That(ex.Message, Is.EqualTo("Private chat not found")); + } + + // Tests for LoadLastMessagesInChatGroup action + [Test] + public async Task LoadLastMessagesInChatGroup_ReturnsMessages_WhenUserIsMember() + { + // Arrange + _groupsRepoMock.Setup(g => g.IsUserMemberOfGroupAsync(_currentUserId, _groupChatId)) + .ReturnsAsync(true); + + var messages = new List + { + new() { Id = Guid.NewGuid(), RecipientId = _groupChatId, RecipientType = RecipientType.Group } + }; + + await _dbContext.Messages.AddRangeAsync(messages); + await _dbContext.SaveChangesAsync(); + + // Act + var result = await _loader.LoadLastMessagesInChatGroup(_groupChatId, _currentUserId, null); + + // Assert + Assert.That(result, Has.Count.EqualTo(1)); + } + + [Test] + public void LoadLastMessagesInChatGroup_Throws_WhenChatIdEmpty() + { + // Act & Assert + var ex = Assert.ThrowsAsync(async () => + await _loader.LoadLastMessagesInChatGroup(Guid.Empty, _currentUserId, null)); + + Assert.That(ex.Message, Does.Contain("Chat id cannot be empty")); + } + + [Test] + public void LoadLastMessagesInChatGroup_Throws_WhenUserNotInGroup() + { + // Arrange + _groupsRepoMock.Setup(g => g.IsUserMemberOfGroupAsync(_currentUserId, _groupChatId)) + .ReturnsAsync(false); + + // Act & Assert + var ex = Assert.ThrowsAsync(async () => + await _loader.LoadLastMessagesInChatGroup(_groupChatId, _currentUserId, null)); + + Assert.That(ex.Message, Is.EqualTo("You are not in a group.")); + } + + [Test] + public async Task LoadLastMessagesInChatGroup_ReturnsEmpty_WhenNoMessages() + { + // Arrange + _groupsRepoMock.Setup(g => g.IsUserMemberOfGroupAsync(_currentUserId, _groupChatId)) + .ReturnsAsync(true); + + // Act + var result = await _loader.LoadLastMessagesInChatGroup(_groupChatId, _currentUserId, null); + + // Assert + Assert.That(result, Is.Empty); + } + + [TearDown] + public void TearDown() + { + _dbContext.Dispose(); + } +} \ No newline at end of file From c0d02e0fa14ba3de1c52ed466e7690c1f2fa216a Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Mon, 21 Jul 2025 14:51:57 +0700 Subject: [PATCH 054/101] Add access control for media downloads Introduced IAccesserToDownloadMedia and its implementation to enforce access checks when downloading media files. Updated MediaController to use the new accesser service and improved error handling and validation in upload/download actions. Refactored and moved MediaService to the Medias namespace, registered new services in DI, and added comprehensive tests for access logic. Also fixed GroupMembershipConfiguration to make InvitationId optional and performed minor test and namespace cleanups. --- .../Controllers/MediaControllerTests.cs | 8 +- Govor.API/Controllers/MediaController.cs | 62 ++++--- .../ConfigurationProgramExtensions.cs | 2 + .../Govor.Application.Tests.csproj | 2 +- .../Validators/UsernameValidatorTests.cs | 2 +- .../AccesserToDownloadMediaServiceTests.cs | 152 ++++++++++++++++++ .../UserSessions/UserSessionOpenerTests.cs | 2 + .../Medias/IAccesserToDownloadMedia.cs | 6 + .../Medias/AccesserToDownloadMediaService.cs | 33 ++++ .../{Messages => Medias}/MediaService.cs | 2 +- .../GroupMembershipConfiguration.cs | 2 +- 11 files changed, 243 insertions(+), 30 deletions(-) rename Govor.Application.Tests/{ => Infrastructure}/Validators/UsernameValidatorTests.cs (95%) create mode 100644 Govor.Application.Tests/Services/Medias/AccesserToDownloadMediaServiceTests.cs create mode 100644 Govor.Application/Interfaces/Medias/IAccesserToDownloadMedia.cs create mode 100644 Govor.Application/Services/Medias/AccesserToDownloadMediaService.cs rename Govor.Application/Services/{Messages => Medias}/MediaService.cs (98%) diff --git a/Govor.API.Tests/IntegrationTests/Controllers/MediaControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/MediaControllerTests.cs index fcb5e4c..9f80ed2 100644 --- a/Govor.API.Tests/IntegrationTests/Controllers/MediaControllerTests.cs +++ b/Govor.API.Tests/IntegrationTests/Controllers/MediaControllerTests.cs @@ -18,6 +18,7 @@ public class MediaControllerTests private Mock _currentUserMock; private Mock> _loggerMock; private Mock _mockMedia; + private Mock _mockAccesser; private MediaController _controller; private Guid _userId = Guid.NewGuid(); @@ -30,11 +31,16 @@ public class MediaControllerTests _currentUserMock = new Mock(); _loggerMock = new Mock>(); + _mockAccesser = new Mock(); _mockMedia = new Mock(); _currentUserMock.Setup(f => f.GetCurrentUserId()).Returns(_userId); - _controller = new MediaController(_loggerMock.Object, _mockMedia.Object, _currentUserMock.Object); + _controller = new MediaController( + _loggerMock.Object, + _mockMedia.Object, + _mockAccesser.Object, + _currentUserMock.Object); } // Test for Upload action diff --git a/Govor.API/Controllers/MediaController.cs b/Govor.API/Controllers/MediaController.cs index 4768b0f..d9ada27 100644 --- a/Govor.API/Controllers/MediaController.cs +++ b/Govor.API/Controllers/MediaController.cs @@ -1,9 +1,6 @@ -using Govor.Application.Interfaces; using Govor.Application.Interfaces.Infrastructure.Extensions; using Govor.Application.Interfaces.Medias; using Govor.Contracts.Requests; -using Govor.Core.Models; -using Govor.Core.Repositories.MediasAttachments; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -16,14 +13,17 @@ public class MediaController : Controller { private readonly ILogger _logger; private readonly IMediaService _mediaService; + private readonly IAccesserToDownloadMedia _accesser; private readonly ICurrentUserService _currentUserService; public MediaController( ILogger logger, IMediaService mediaService, + IAccesserToDownloadMedia accesser, ICurrentUserService currentUserService) { _logger = logger; + _accesser = accesser; _mediaService = mediaService; _currentUserService = currentUserService; } @@ -32,20 +32,21 @@ public class MediaController : Controller [RequestSizeLimit(20_000_000)] // ~20MB public async Task Upload([FromForm] MediaUploadRequest request) { + if (!ModelState.IsValid) + return BadRequest(ModelState); + + if (request?.FromFile is null || request.FromFile.Length == 0) + return BadRequest("No file uploaded"); + + if (request.FromFile.Length > 20_000_000) + return BadRequest("File is too large"); + + if (string.IsNullOrWhiteSpace(request.MimeType)) + return BadRequest("Missing MIME type"); + try { - if (request.FromFile.Length > 20_000_000) - return BadRequest("File is too large"); - - if (!ModelState.IsValid) - return BadRequest(ModelState); - - // Чтение байт из IFormFile - using var memoryStream = new MemoryStream(); - - await request.FromFile.CopyToAsync(memoryStream); - - byte[] fileBytes = memoryStream.ToArray(); + byte[] fileBytes = await ReadFileAsync(request.FromFile); var media = new Media( _currentUserService.GetCurrentUserId(), @@ -59,21 +60,21 @@ public class MediaController : Controller var result = await _mediaService.UploadMediaAsync(media); - _logger.LogInformation( - $"Uploaded file: {Path.GetFileName(request.FromFile.FileName)} from user {_currentUserService.GetCurrentUserId()}"); + _logger.LogInformation("Uploaded file {FileName} from user {UserId}", + media.FileName, media.UploaderId); return Ok(result); } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, ex.Message); - return BadRequest(ex.Message); - } catch (UnauthorizedAccessException ex) { _logger.LogWarning(ex, ex.Message); return Unauthorized(ex.Message); } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, ex.Message); + return BadRequest(ex.Message); + } catch (Exception ex) { _logger.LogError(ex, "Error uploading media"); @@ -81,21 +82,32 @@ public class MediaController : Controller } } + private async Task ReadFileAsync(IFormFile file) + { + await using var ms = new MemoryStream(); + await file.CopyToAsync(ms); + return ms.ToArray(); + } + + [HttpGet("download/{id}")] public async Task Download(Guid id) { try { - if (!ModelState.IsValid) - return BadRequest(ModelState); + var userId = _currentUserService.GetCurrentUserId(); + + if (!await _accesser.HasAccessAsync(id, userId)) + return Forbid(); var media = await _mediaService.GetMediaByIdAsync(id); + return File(media.Data, media.MineType, Path.GetFileName(media.FileName)); } catch (KeyNotFoundException ex) { _logger.LogWarning(ex, ex.Message); - return NotFound(ex.Message); + return NotFound("Media not found"); } catch (Exception ex) { diff --git a/Govor.API/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Extensions/ConfigurationProgramExtensions.cs index d7ced36..d169afb 100644 --- a/Govor.API/Extensions/ConfigurationProgramExtensions.cs +++ b/Govor.API/Extensions/ConfigurationProgramExtensions.cs @@ -11,6 +11,7 @@ using Govor.Application.Interfaces.UserSession; using Govor.Application.Services; using Govor.Application.Services.Authentication; using Govor.Application.Services.Friends; +using Govor.Application.Services.Medias; using Govor.Application.Services.Messages; using Govor.Application.Services.UserSessions; using Govor.Core.Infrastructure.Extensions; @@ -68,6 +69,7 @@ public static class ConfigurationProgramExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // UserSession services.AddScoped(); diff --git a/Govor.Application.Tests/Govor.Application.Tests.csproj b/Govor.Application.Tests/Govor.Application.Tests.csproj index cdd654f..b9c4e8e 100644 --- a/Govor.Application.Tests/Govor.Application.Tests.csproj +++ b/Govor.Application.Tests/Govor.Application.Tests.csproj @@ -27,7 +27,7 @@ - + diff --git a/Govor.Application.Tests/Validators/UsernameValidatorTests.cs b/Govor.Application.Tests/Infrastructure/Validators/UsernameValidatorTests.cs similarity index 95% rename from Govor.Application.Tests/Validators/UsernameValidatorTests.cs rename to Govor.Application.Tests/Infrastructure/Validators/UsernameValidatorTests.cs index 1e4571a..60daae4 100644 --- a/Govor.Application.Tests/Validators/UsernameValidatorTests.cs +++ b/Govor.Application.Tests/Infrastructure/Validators/UsernameValidatorTests.cs @@ -1,7 +1,7 @@ using Govor.Application.Exceptions.AuthService; using Govor.Application.Infrastructure.Validators; -namespace Govor.API.Tests.UnitTests.Services.Validators; +namespace Govor.Application.Tests.Infrastructure.Validators; [TestFixture] public class UsernameValidatorTests diff --git a/Govor.Application.Tests/Services/Medias/AccesserToDownloadMediaServiceTests.cs b/Govor.Application.Tests/Services/Medias/AccesserToDownloadMediaServiceTests.cs new file mode 100644 index 0000000..183d562 --- /dev/null +++ b/Govor.Application.Tests/Services/Medias/AccesserToDownloadMediaServiceTests.cs @@ -0,0 +1,152 @@ +using Govor.Application.Services.Medias; +using Govor.Core.Models; +using Govor.Core.Models.Messages; +using Govor.Data; +using Microsoft.EntityFrameworkCore; + +namespace Govor.Application.Tests.Services.Medias; + +[TestFixture] +public class AccesserToDownloadMediaServiceTests +{ + private GovorDbContext _dbContext = null!; + private AccesserToDownloadMediaService _accesser = null!; + private Guid _userId; + private Guid _otherUserId; + private Guid _groupId; + private Guid _mediaFileId; + + [SetUp] + public async Task SetUp() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + _dbContext = new GovorDbContext(options); + _accesser = new AccesserToDownloadMediaService(_dbContext); + + _userId = Guid.NewGuid(); + _otherUserId = Guid.NewGuid(); + _groupId = Guid.NewGuid(); + _mediaFileId = Guid.NewGuid(); + + // Seed message from user to other user + var message = new Message + { + Id = Guid.NewGuid(), + SenderId = _userId, + RecipientId = _otherUserId, + RecipientType = RecipientType.User + }; + + + var media = new MediaFile + { + Id = _mediaFileId, + Url = "/media/test.png", + MineType = "image/png", + MediaType = MediaType.Image, + UploaderId = _userId, + DateCreated = DateTime.UtcNow + }; + + var attachment = new MediaAttachments + { + Id = Guid.NewGuid(), + MediaFileId = _mediaFileId, + MessageId = message.Id, + Message = message, + MediaFile = media + }; + + await _dbContext.Messages.AddAsync(message); + await _dbContext.MediaFiles.AddAsync(media); + await _dbContext.MediaAttachments.AddAsync(attachment); + await _dbContext.SaveChangesAsync(); + } + + [Test] + public async Task HasAccessAsync_ReturnsTrue_ForSender() + { + var result = await _accesser.HasAccessAsync(_mediaFileId, _userId); + Assert.That(result, Is.True); + } + + [Test] + public async Task HasAccessAsync_ReturnsTrue_ForRecipient() + { + var result = await _accesser.HasAccessAsync(_mediaFileId, _otherUserId); + Assert.That(result, Is.True); + } + + [Test] + public async Task HasAccessAsync_ReturnsFalse_ForUnrelatedUser() + { + var unrelatedUserId = Guid.NewGuid(); + var result = await _accesser.HasAccessAsync(_mediaFileId, unrelatedUserId); + Assert.That(result, Is.False); + } + + [Test] + public async Task HasAccessAsync_ReturnsTrue_ForGroupMember() + { + var groupMediaId = Guid.NewGuid(); + + var groupMessage = new Message + { + Id = Guid.NewGuid(), + SenderId = _userId, + RecipientId = _groupId, + RecipientType = RecipientType.Group + }; + + var media = new MediaFile + { + Id = groupMediaId, + Url = "/media/group.png", + MineType = "image/png", + MediaType = MediaType.Image, + UploaderId = _userId, + DateCreated = DateTime.UtcNow + }; + + var attachment = new MediaAttachments + { + Id = Guid.NewGuid(), + MediaFileId = groupMediaId, + MessageId = groupMessage.Id, + Message = groupMessage, + MediaFile = media + }; + + var membership = new GroupMembership + { + Id = Guid.NewGuid(), + GroupId = _groupId, + UserId = _otherUserId + }; + + await _dbContext.Messages.AddAsync(groupMessage); + await _dbContext.MediaFiles.AddAsync(media); + await _dbContext.MediaAttachments.AddAsync(attachment); + await _dbContext.GroupMemberships.AddAsync(membership); + await _dbContext.SaveChangesAsync(); + + var result = await _accesser.HasAccessAsync(groupMediaId, _otherUserId); + Assert.That(result, Is.True); + } + + [Test] + public async Task HasAccessAsync_ReturnsFalse_IfMediaNotAttached() + { + var result = await _accesser.HasAccessAsync(Guid.NewGuid(), _userId); + Assert.That(result, Is.False); + } + + [TearDown] + public void TearDown() + { + _dbContext.Dispose(); + } +} diff --git a/Govor.Application.Tests/Services/UserSessions/UserSessionOpenerTests.cs b/Govor.Application.Tests/Services/UserSessions/UserSessionOpenerTests.cs index 490cf51..278c4f5 100644 --- a/Govor.Application.Tests/Services/UserSessions/UserSessionOpenerTests.cs +++ b/Govor.Application.Tests/Services/UserSessions/UserSessionOpenerTests.cs @@ -8,6 +8,8 @@ using Moq; using Govor.Application.Interfaces.Authentication; using Govor.Application.Services.Authentication; +namespace Govor.Application.Tests.Services.UserSessions; + [TestFixture] public class UserSessionOpenerTests { diff --git a/Govor.Application/Interfaces/Medias/IAccesserToDownloadMedia.cs b/Govor.Application/Interfaces/Medias/IAccesserToDownloadMedia.cs new file mode 100644 index 0000000..a55669e --- /dev/null +++ b/Govor.Application/Interfaces/Medias/IAccesserToDownloadMedia.cs @@ -0,0 +1,6 @@ +namespace Govor.Application.Interfaces.Medias; + +public interface IAccesserToDownloadMedia +{ + Task HasAccessAsync(Guid mediaFileId, Guid userId); +} \ No newline at end of file diff --git a/Govor.Application/Services/Medias/AccesserToDownloadMediaService.cs b/Govor.Application/Services/Medias/AccesserToDownloadMediaService.cs new file mode 100644 index 0000000..305ea53 --- /dev/null +++ b/Govor.Application/Services/Medias/AccesserToDownloadMediaService.cs @@ -0,0 +1,33 @@ +using Govor.Application.Interfaces.Medias; +using Govor.Core.Models.Messages; +using Govor.Data; +using Microsoft.EntityFrameworkCore; + +namespace Govor.Application.Services.Medias; + +public class AccesserToDownloadMediaService : IAccesserToDownloadMedia +{ + private readonly GovorDbContext _dbContext; + + public AccesserToDownloadMediaService(GovorDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task HasAccessAsync(Guid mediaFileId, Guid userId) + { + return await _dbContext.MediaAttachments + .Include(ma => ma.Message) + .AnyAsync(ma => + ma.MediaFileId == mediaFileId && + ( + (ma.Message.RecipientType == RecipientType.User && + (ma.Message.SenderId == userId || ma.Message.RecipientId == userId)) + || + (ma.Message.RecipientType == RecipientType.Group && + _dbContext.GroupMemberships.Any(gm => + gm.GroupId == ma.Message.RecipientId && + gm.UserId == userId)) + )); + } +} \ No newline at end of file diff --git a/Govor.Application/Services/Messages/MediaService.cs b/Govor.Application/Services/Medias/MediaService.cs similarity index 98% rename from Govor.Application/Services/Messages/MediaService.cs rename to Govor.Application/Services/Medias/MediaService.cs index 547e393..a8e9ff9 100644 --- a/Govor.Application/Services/Messages/MediaService.cs +++ b/Govor.Application/Services/Medias/MediaService.cs @@ -5,7 +5,7 @@ using Govor.Data; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -namespace Govor.Application.Services.Messages; +namespace Govor.Application.Services.Medias; public class MediaService : IMediaService { diff --git a/Govor.Data/Configurations/GroupMembershipConfiguration.cs b/Govor.Data/Configurations/GroupMembershipConfiguration.cs index 9cee30b..fb16926 100644 --- a/Govor.Data/Configurations/GroupMembershipConfiguration.cs +++ b/Govor.Data/Configurations/GroupMembershipConfiguration.cs @@ -12,7 +12,7 @@ public class GroupMembershipConfiguration : IEntityTypeConfiguration e.UserId).IsRequired(); builder.Property(e => e.GroupId).IsRequired(); - builder.Property(e => e.InvitationId).IsRequired(); + builder.Property(e => e.InvitationId).IsRequired(false); // Optional: можно добавить навигацию к GroupInvitation builder.HasOne() From c434ca447c6879673397ac7754c92f68716713a0 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Mon, 21 Jul 2025 16:18:04 +0700 Subject: [PATCH 055/101] Media Controller Tests and other --- .../Controllers/MediaControllerTests.cs | 269 +++++++++++++++++- Govor.API/Controllers/MediaController.cs | 2 +- Govor.API/Hubs/ChatsHub.cs | 16 +- Govor.API/Hubs/FriendsHub.cs | 7 +- .../Interfaces/Medias/IMediaService.cs | 2 +- .../Messages/IMessageCommandService.cs | 4 +- .../Services/Medias/MediaService.cs | 2 +- .../Messages/MessageCommandService.cs | 6 +- 8 files changed, 286 insertions(+), 22 deletions(-) diff --git a/Govor.API.Tests/IntegrationTests/Controllers/MediaControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/MediaControllerTests.cs index 9f80ed2..ad5b5dc 100644 --- a/Govor.API.Tests/IntegrationTests/Controllers/MediaControllerTests.cs +++ b/Govor.API.Tests/IntegrationTests/Controllers/MediaControllerTests.cs @@ -1,3 +1,4 @@ +using System.Text; using AutoFixture; using Govor.API.Controllers; using Govor.Application.Interfaces.Infrastructure.Extensions; @@ -43,7 +44,7 @@ public class MediaControllerTests _currentUserMock.Object); } - // Test for Upload action + // Tests for Upload action [Test] public async Task Upload_ValidRequest_ReturnsOkResult() { @@ -86,6 +87,272 @@ public class MediaControllerTests Assert.That(value.MediaId, Is.EqualTo(uploadResult.MediaId)); } + [Test] + public async Task Upload_InvalidModelState_ReturnsBadRequest() + { + // Arrange + _controller.ModelState.AddModelError("Error", "Invalid model state"); + var content = "fake file content"; + var fileName = "testfile.txt"; + var fileBytes = System.Text.Encoding.UTF8.GetBytes(content); + var stream = new MemoryStream(fileBytes); + + var formFileMock = new Mock(); + formFileMock.Setup(f => f.Length).Returns(fileBytes.Length); + formFileMock.Setup(f => f.FileName).Returns(fileName); + formFileMock.Setup(f => f.OpenReadStream()).Returns(stream); + formFileMock.Setup(f => f.CopyToAsync(It.IsAny(), default)) + .Returns((target, _) => stream.CopyToAsync(target)); + + var uploadRequest = _fixture.Build() + .With(r => r.FromFile, formFileMock.Object) + .With(r => r.Type, MediaType.Image) + .With(r => r.MimeType, "image/png") + .With(r => r.EncryptedKey, "secret") + .Create(); + + // Act + var result = await _controller.Upload(uploadRequest); + + // Assert + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task Upload_NoFileUploaded_ReturnsBadRequest() + { + // Arrange + var uploadRequest = _fixture.Build() + .With(r => r.FromFile, (IFormFile)null) + .Create(); + + // Act + var result = await _controller.Upload(uploadRequest); + + // Assert + Assert.That(result, Is.InstanceOf()); + var badRequestResult = result as BadRequestObjectResult; + Assert.That(badRequestResult?.Value, Is.EqualTo("No file uploaded")); + } + + [Test] + public async Task Upload_EmptyFile_ReturnsBadRequest() + { + // Arrange + var formFileMock = new Mock(); + formFileMock.Setup(f => f.Length).Returns(0); + + var uploadRequest = _fixture.Build() + .With(r => r.FromFile, formFileMock.Object) + .Create(); + + // Act + var result = await _controller.Upload(uploadRequest); + + // Assert + Assert.That(result, Is.InstanceOf()); + var badRequestResult = result as BadRequestObjectResult; + Assert.That(badRequestResult?.Value, Is.EqualTo("No file uploaded")); + } + + [Test] + public async Task Upload_FileTooLarge_ReturnsBadRequest() + { + // Arrange + var formFileMock = new Mock(); + formFileMock.Setup(f => f.Length).Returns(20_000_001); // Just over 20MB + + var uploadRequest = _fixture.Build() + .With(r => r.FromFile, formFileMock.Object) + .Create(); + + // Act + var result = await _controller.Upload(uploadRequest); + + // Assert + Assert.That(result, Is.InstanceOf()); + var badRequestResult = result as BadRequestObjectResult; + Assert.That(badRequestResult?.Value, Is.EqualTo("File is too large")); + } + + [Test] + public async Task Upload_MissingMimeType_ReturnsBadRequest() + { + // Arrange + var formFileMock = new Mock(); + formFileMock.Setup(f => f.Length).Returns(1000); + formFileMock.Setup(f => f.FileName).Returns("testfile.txt"); + + var uploadRequest = _fixture.Build() + .With(r => r.FromFile, formFileMock.Object) + .With(r => r.MimeType, string.Empty) + .Create(); + + // Act + var result = await _controller.Upload(uploadRequest); + + // Assert + Assert.That(result, Is.InstanceOf()); + var badRequestResult = result as BadRequestObjectResult; + Assert.That(badRequestResult?.Value, Is.EqualTo("Missing MIME type")); + } + + [Test] + public async Task Upload_UnauthorizedAccess_ReturnsUnauthorized() + { + // Arrange + var formFileMock = new Mock(); + formFileMock.Setup(f => f.Length).Returns(1000); + formFileMock.Setup(f => f.FileName).Returns("testfile.txt"); + formFileMock.Setup(f => f.OpenReadStream()).Returns(new MemoryStream(new byte[1000])); + + var uploadRequest = _fixture.Build() + .With(r => r.FromFile, formFileMock.Object) + .With(r => r.MimeType, "text/plain") + .Create(); + + _mockMedia.Setup(f => f.UploadMediaAsync(It.IsAny())) + .ThrowsAsync(new UnauthorizedAccessException("Access denied")); + + // Act + var result = await _controller.Upload(uploadRequest); + + // Assert + Assert.That(result, Is.InstanceOf()); + var unauthorizedResult = result as UnauthorizedObjectResult; + Assert.That(unauthorizedResult?.Value, Is.EqualTo("Access denied")); + } + + [Test] + public async Task Upload_InvalidOperation_ReturnsBadRequest() + { + // Arrange + var formFileMock = new Mock(); + formFileMock.Setup(f => f.Length).Returns(1000); + formFileMock.Setup(f => f.FileName).Returns("testfile.txt"); + formFileMock.Setup(f => f.OpenReadStream()).Returns(new MemoryStream(new byte[1000])); + + var uploadRequest = _fixture.Build() + .With(r => r.FromFile, formFileMock.Object) + .With(r => r.MimeType, "text/plain") + .Create(); + + _mockMedia.Setup(f => f.UploadMediaAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Invalid operation")); + + // Act + var result = await _controller.Upload(uploadRequest); + + // Assert + Assert.That(result, Is.InstanceOf()); + var badRequestResult = result as BadRequestObjectResult; + Assert.That(badRequestResult?.Value, Is.EqualTo("Invalid operation")); + } + + [Test] + public async Task Upload_GeneralException_ReturnsInternalServerError() + { + // Arrange + var formFileMock = new Mock(); + formFileMock.Setup(f => f.Length).Returns(1000); + formFileMock.Setup(f => f.FileName).Returns("testfile.txt"); + formFileMock.Setup(f => f.OpenReadStream()).Returns(new MemoryStream(new byte[1000])); + + var uploadRequest = _fixture.Build() + .With(r => r.FromFile, formFileMock.Object) + .With(r => r.MimeType, "text/plain") + .Create(); + + _mockMedia.Setup(f => f.UploadMediaAsync(It.IsAny())) + .ThrowsAsync(new Exception("Something went wrong")); + + // Act + var result = await _controller.Upload(uploadRequest); + + // Assert + Assert.That(result, Is.InstanceOf()); + var objectResult = result as ObjectResult; + Assert.That(objectResult.StatusCode, Is.EqualTo(500)); + } + + // Tests for Download action + [Test] + public async Task Download_HasAccessAndMediaExists_ReturnsFile() + { + // Arrange + var mediaId = Guid.NewGuid(); + var media = _fixture.Build() + .With(m => m.Data, Encoding.UTF8.GetBytes("fake file content")) + .With(m => m.MimeType, "application/octet-stream") // Ensure MimeType is set + .With(m => m.FileName, "testfile.txt") + .Create(); + + _mockAccesser.Setup(f => f.HasAccessAsync(mediaId, _userId)).ReturnsAsync(true); + _mockMedia.Setup(f => f.GetMediaByIdAsync(mediaId)).ReturnsAsync(media); + + // Act + var result = await _controller.Download(mediaId); + + // Assert + Assert.That(result, Is.InstanceOf()); + var fileResult = result as FileContentResult; + Assert.That(fileResult?.FileContents, Is.EqualTo(media.Data)); + Assert.That(fileResult?.ContentType, Is.EqualTo(media.MimeType)); // Changed MineType to MimeType + Assert.That(fileResult?.FileDownloadName, Is.EqualTo(Path.GetFileName(media.FileName))); + } + + [Test] + public async Task Download_NoAccess_ReturnsForbid() + { + // Arrange + var mediaId = Guid.NewGuid(); + + _mockAccesser.Setup(f => f.HasAccessAsync(mediaId, _userId)).ReturnsAsync(false); + + // Act + var result = await _controller.Download(mediaId); + + // Assert + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task Download_MediaNotFound_ReturnsNotFound() + { + // Arrange + var mediaId = Guid.NewGuid(); + + _mockAccesser.Setup(f => f.HasAccessAsync(mediaId, _userId)).ReturnsAsync(true); + _mockMedia.Setup(f => f.GetMediaByIdAsync(mediaId)) + .ThrowsAsync(new KeyNotFoundException("Media not found")); + + // Act + var result = await _controller.Download(mediaId); + + // Assert + Assert.That(result, Is.InstanceOf()); + var notFoundResult = result as NotFoundObjectResult; + Assert.That(notFoundResult?.Value, Is.EqualTo("Media not found")); + } + + [Test] + public async Task Download_GeneralException_ReturnsInternalServerError() + { + // Arrange + var mediaId = Guid.NewGuid(); + + _mockAccesser.Setup(f => f.HasAccessAsync(mediaId, _userId)).ReturnsAsync(true); + _mockMedia.Setup(f => f.GetMediaByIdAsync(mediaId)) + .ThrowsAsync(new Exception("Something went wrong")); + + // Act + var result = await _controller.Download(mediaId); + + // Assert + Assert.That(result, Is.InstanceOf()); + var objectResult = result as ObjectResult; + Assert.That(objectResult.StatusCode, Is.EqualTo(500)); + } [TearDown] public void TearDown() diff --git a/Govor.API/Controllers/MediaController.cs b/Govor.API/Controllers/MediaController.cs index d9ada27..e48e45d 100644 --- a/Govor.API/Controllers/MediaController.cs +++ b/Govor.API/Controllers/MediaController.cs @@ -102,7 +102,7 @@ public class MediaController : Controller var media = await _mediaService.GetMediaByIdAsync(id); - return File(media.Data, media.MineType, Path.GetFileName(media.FileName)); + return File(media.Data, media.MimeType, Path.GetFileName(media.FileName)); } catch (KeyNotFoundException ex) { diff --git a/Govor.API/Hubs/ChatsHub.cs b/Govor.API/Hubs/ChatsHub.cs index 4869472..2f50ea8 100644 --- a/Govor.API/Hubs/ChatsHub.cs +++ b/Govor.API/Hubs/ChatsHub.cs @@ -30,16 +30,16 @@ public class ChatsHub : Hub if (userId == Guid.Empty) { _logger.LogWarning("User connected with invalid UserID claim."); - Context.Abort(); // Abort connection if userID is invalid + Context.Abort(); return; } - - // Add user to their own group (for private messages and notifications) + await Groups.AddToGroupAsync(Context.ConnectionId, userId.ToString()); _logger.LogInformation("User {UserId} connected with ConnectionId {ConnectionId} and added to their group", userId, Context.ConnectionId); var userGroups = await _userService.GetUserGroupsAsync(userId); + foreach (var group in userGroups) { await Groups.AddToGroupAsync(Context.ConnectionId, $"group_{group.Id}"); @@ -51,19 +51,17 @@ public class ChatsHub : Hub public override async Task OnDisconnectedAsync(Exception? exception) { var userId = - GetUserId(suppressException: true); // Suppress exception if userID is not found (e.g. connection aborted early) + GetUserId(suppressException: true); if (userId != Guid.Empty) { - // Remove user from their own group await Groups.RemoveFromGroupAsync(Context.ConnectionId, userId.ToString()); - _logger.LogInformation( - "User {UserId} disconnected with ConnectionId {ConnectionId} and removed from their group", userId, - Context.ConnectionId); + _logger.LogInformation("User {UserId} disconnected with ConnectionId {ConnectionId} and removed from their group", + userId, Context.ConnectionId); var userGroups = await _userService .GetUserGroupsAsync( - userId); // This might be problematic if the service relies on the user being connected + userId); foreach (var group in userGroups) { await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"group_{group.Id}"); diff --git a/Govor.API/Hubs/FriendsHub.cs b/Govor.API/Hubs/FriendsHub.cs index b252080..3fa025e 100644 --- a/Govor.API/Hubs/FriendsHub.cs +++ b/Govor.API/Hubs/FriendsHub.cs @@ -33,11 +33,10 @@ public class FriendsHub : Hub if (userId == Guid.Empty) { _logger.LogWarning("User connected with invalid UserID claim."); - Context.Abort(); // Abort connection if userID is invalid + Context.Abort(); return; } - - // Add user to their own group (for private messages and notifications) + await Groups.AddToGroupAsync(Context.ConnectionId, userId.ToString()); _logger.LogInformation("User {UserId} connected with ConnectionId {ConnectionId} and added to their group", userId, Context.ConnectionId); @@ -48,7 +47,7 @@ public class FriendsHub : Hub public override async Task OnDisconnectedAsync(Exception? exception) { var userId = - GetUserId(suppressException: true); // Suppress exception if userID is not found (e.g. connection aborted early) + GetUserId(suppressException: true); if (userId != Guid.Empty) { // Remove user from their own group diff --git a/Govor.Application/Interfaces/Medias/IMediaService.cs b/Govor.Application/Interfaces/Medias/IMediaService.cs index b8a9735..30663ad 100644 --- a/Govor.Application/Interfaces/Medias/IMediaService.cs +++ b/Govor.Application/Interfaces/Medias/IMediaService.cs @@ -15,7 +15,7 @@ public record Media(Guid UploaderId, string FileName, byte[] Data, MediaType Type, - string MineType, + string MimeType, string EncryptedKey); public record MediaUploadResult(Guid? MediaId, string Url); \ No newline at end of file diff --git a/Govor.Application/Interfaces/Messages/IMessageCommandService.cs b/Govor.Application/Interfaces/Messages/IMessageCommandService.cs index e38aa15..b927074 100644 --- a/Govor.Application/Interfaces/Messages/IMessageCommandService.cs +++ b/Govor.Application/Interfaces/Messages/IMessageCommandService.cs @@ -15,7 +15,7 @@ public interface IMessageCommandService // Task MarkMessageAsReadAsync(Guid userId, Guid messageId); } -// Define specific result types for clarity, including original message for notifications if needed + public record SendMessageResult(bool IsSuccess, Exception? Exception, Message Message) : Result(IsSuccess, Exception, Message?.Id ?? Guid.Empty); @@ -23,7 +23,7 @@ public record SendMessageResult(bool IsSuccess, Exception? Exception, Message Me public record EditMessageResult(bool IsSuccess, Exception? Exception, Message? OriginalMessage) : Result(IsSuccess, Exception, OriginalMessage?.Id ?? Guid.Empty) { - // OriginalMessage can be useful for the Hub to know details like RecipientType, RecipientId for notifications + } public record DeleteMessageResult(bool IsSuccess, Exception? Exception, Message? OriginalMessage) diff --git a/Govor.Application/Services/Medias/MediaService.cs b/Govor.Application/Services/Medias/MediaService.cs index a8e9ff9..8e62ed6 100644 --- a/Govor.Application/Services/Medias/MediaService.cs +++ b/Govor.Application/Services/Medias/MediaService.cs @@ -34,7 +34,7 @@ public class MediaService : IMediaService UploaderId = file.UploaderId, DateCreated = file.UploadedOn, MediaType = file.Type, - MineType = file.MineType, + MineType = file.MimeType, Url = url }); diff --git a/Govor.Application/Services/Messages/MessageCommandService.cs b/Govor.Application/Services/Messages/MessageCommandService.cs index 09b59d1..abaa464 100644 --- a/Govor.Application/Services/Messages/MessageCommandService.cs +++ b/Govor.Application/Services/Messages/MessageCommandService.cs @@ -15,10 +15,10 @@ namespace Govor.Application.Services.Messages; public class MessageCommandService : IMessageCommandService { private readonly IMessagesRepository _messagesRepository; - private readonly IUsersRepository _usersRepository; // For validating user recipients - private readonly IGroupsRepository _groupsRepository; // For validating group recipients and fetching members + private readonly IUsersRepository _usersRepository; + private readonly IGroupsRepository _groupsRepository; private readonly IPrivateChatsRepository _privateChats; - private readonly IVerifyFriendship _verifyFriendship; // For private messages + private readonly IVerifyFriendship _verifyFriendship; private readonly ILogger _logger; public MessageCommandService( From 4054e4046a6e6020e96a587b9dda98e45debf93b Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Mon, 21 Jul 2025 17:06:01 +0700 Subject: [PATCH 056/101] New Settings entity --- Govor.Core/Models/Users/PrivacyRuleEntity.cs | 15 ++++++++ .../Models/Users/PrivacyUserSettings.cs | 37 +++++++++++-------- .../PrivacyRuleEntityConfiguration.cs | 23 ++++++++++++ .../PrivacyUserSettingsConfiguration.cs | 29 +++++++++++++++ 4 files changed, 89 insertions(+), 15 deletions(-) create mode 100644 Govor.Core/Models/Users/PrivacyRuleEntity.cs create mode 100644 Govor.Data/Configurations/PrivacyRuleEntityConfiguration.cs create mode 100644 Govor.Data/Configurations/PrivacyUserSettingsConfiguration.cs diff --git a/Govor.Core/Models/Users/PrivacyRuleEntity.cs b/Govor.Core/Models/Users/PrivacyRuleEntity.cs new file mode 100644 index 0000000..09f1fe0 --- /dev/null +++ b/Govor.Core/Models/Users/PrivacyRuleEntity.cs @@ -0,0 +1,15 @@ +namespace Govor.Core.Models.Users; + +public class PrivacyRuleEntity +{ + public Guid Id { get; set; } + public Guid OwnerId { get; set; } + + public PrivacyTargetArea Area { get; set; } + public WhoCan AccessType { get; set; } // Everyone, Friends, None + + public List Whitelist { get; set; } = new(); + public List Blacklist { get; set; } = new(); + + public PrivacyUserSettings OwnerSettings { get; set; } = null!; +} \ No newline at end of file diff --git a/Govor.Core/Models/Users/PrivacyUserSettings.cs b/Govor.Core/Models/Users/PrivacyUserSettings.cs index c0a19a8..9997c9f 100644 --- a/Govor.Core/Models/Users/PrivacyUserSettings.cs +++ b/Govor.Core/Models/Users/PrivacyUserSettings.cs @@ -3,22 +3,15 @@ namespace Govor.Core.Models.Users; public class PrivacyUserSettings { public Guid UserId { get; set; } - public bool IsGlobalAccount { get; set; } - - public WhoCan CanSend { get; set; } - public List? WhitelistSent { get; set; } - public List? BlacklistSent { get; set; } - - public WhoCan CanSeeTimeWas { get; set; } - public List? WhitelistTimeWas { get; set; } - public List? BlacklistTimeWas { get; set; } - - public WhoCan CanSeeImage { get; set; } - public List? WhitelistSeeImage { get; set; } - public List? BlacklistSeeImage{ get; set; } - - public DeletingMessagesVia Via { get; set; } // if min value = none + + public bool IsGlobalAccount { get; set; } + + public DeletingMessagesVia DeletingVia { get; set; } public int DeletingIn { get; set; } + + public bool IsInvisibleMode { get; set; } + + public List Rules { get; set; } = new(); } public enum WhoCan @@ -37,3 +30,17 @@ public enum DeletingMessagesVia Years = 4 } +public enum PrivacyTargetArea +{ + CanSend = 0, + CanSeeTimeWas = 1, + CanSeeImage = 2 +} + +public enum PrivacyRuleType +{ + Allow = 0, + Deny = 1 +} + + diff --git a/Govor.Data/Configurations/PrivacyRuleEntityConfiguration.cs b/Govor.Data/Configurations/PrivacyRuleEntityConfiguration.cs new file mode 100644 index 0000000..226fb6f --- /dev/null +++ b/Govor.Data/Configurations/PrivacyRuleEntityConfiguration.cs @@ -0,0 +1,23 @@ +using Govor.Core.Models.Users; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Govor.Data.Configurations; + + +public class PrivacyRuleEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(r => r.Id); + + builder.Property(r => r.OwnerId).IsRequired(); + builder.Property(r => r.Area).IsRequired().HasConversion(); + builder.Property(r => r.AccessType).IsRequired().HasConversion(); + + builder.Property(r => r.Whitelist).HasColumnType("jsonb"); + builder.Property(r => r.Blacklist).HasColumnType("jsonb"); + + builder.HasIndex(r => r.OwnerId); + } +} \ No newline at end of file diff --git a/Govor.Data/Configurations/PrivacyUserSettingsConfiguration.cs b/Govor.Data/Configurations/PrivacyUserSettingsConfiguration.cs new file mode 100644 index 0000000..7582db7 --- /dev/null +++ b/Govor.Data/Configurations/PrivacyUserSettingsConfiguration.cs @@ -0,0 +1,29 @@ +using Govor.Core.Models.Users; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Govor.Data.Configurations; + +public class PrivacyUserSettingsConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + // Primary Key + builder.HasKey(p => p.UserId); + + builder.Property(p => p.DeletingVia) + .IsRequired() + .HasConversion() + .HasDefaultValue(DeletingMessagesVia.None); // Adjust default as needed + + builder.Property(p => p.DeletingIn) + .IsRequired() + .HasDefaultValue(0); + + // Relationship Configuration + builder.HasMany(p => p.Rules) + .WithOne(r => r.OwnerSettings) + .HasForeignKey(r => r.OwnerId) + .OnDelete(DeleteBehavior.Cascade); + } +} From 93004a26b3f827350b72715cead482193eae3810 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Mon, 21 Jul 2025 17:16:51 +0700 Subject: [PATCH 057/101] Unauthorized changet to Forbid --- Govor.API/Controllers/ChatLoadController.cs | 6 +++--- Govor.API/Controllers/MediaController.cs | 2 +- Govor.API/Controllers/OnlinePingingController.cs | 5 ----- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/Govor.API/Controllers/ChatLoadController.cs b/Govor.API/Controllers/ChatLoadController.cs index 4a1fa6f..36fd42d 100644 --- a/Govor.API/Controllers/ChatLoadController.cs +++ b/Govor.API/Controllers/ChatLoadController.cs @@ -8,7 +8,7 @@ using Microsoft.AspNetCore.Mvc; namespace Govor.API.Controllers; [ApiController] -[Authorize] +[Authorize(Roles = "Admin, User")] [Route("api/chats")] public class ChatLoadController : Controller { @@ -49,7 +49,7 @@ public class ChatLoadController : Controller catch (UnauthorizedAccessException ex) { _logger.LogWarning(ex.Message); - return Unauthorized(ex.Message); + return Forbid(ex.Message); } catch (Exception ex) { @@ -84,7 +84,7 @@ public class ChatLoadController : Controller catch (UnauthorizedAccessException ex) { _logger.LogWarning(ex.Message); - return Unauthorized(ex.Message); + return Forbid(ex.Message); } catch (Exception ex) { diff --git a/Govor.API/Controllers/MediaController.cs b/Govor.API/Controllers/MediaController.cs index e48e45d..bec6eda 100644 --- a/Govor.API/Controllers/MediaController.cs +++ b/Govor.API/Controllers/MediaController.cs @@ -68,7 +68,7 @@ public class MediaController : Controller catch (UnauthorizedAccessException ex) { _logger.LogWarning(ex, ex.Message); - return Unauthorized(ex.Message); + return Forbid(ex.Message); } catch (InvalidOperationException ex) { diff --git a/Govor.API/Controllers/OnlinePingingController.cs b/Govor.API/Controllers/OnlinePingingController.cs index 871e960..9d54551 100644 --- a/Govor.API/Controllers/OnlinePingingController.cs +++ b/Govor.API/Controllers/OnlinePingingController.cs @@ -43,11 +43,6 @@ public class OnlinePingingController : Controller _logger.LogError(e, e.Message); return BadRequest("User can't be found in our database."); } - catch (UnauthorizedAccessException e) - { - _logger.LogError(e, e.Message); - return Forbid(e.Message); - } catch (Exception e) { _logger.LogError(e, e.Message); From 10b4e5207f2cddb1d46ad78f9f4a7bb82d6fb1b8 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Mon, 21 Jul 2025 17:20:35 +0700 Subject: [PATCH 058/101] bug fix --- .../IntegrationTests/Controllers/MediaControllerTests.cs | 6 +++--- Govor.API/Controllers/OnlinePingingController.cs | 5 +++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Govor.API.Tests/IntegrationTests/Controllers/MediaControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/MediaControllerTests.cs index ad5b5dc..0cf8f7e 100644 --- a/Govor.API.Tests/IntegrationTests/Controllers/MediaControllerTests.cs +++ b/Govor.API.Tests/IntegrationTests/Controllers/MediaControllerTests.cs @@ -218,9 +218,9 @@ public class MediaControllerTests var result = await _controller.Upload(uploadRequest); // Assert - Assert.That(result, Is.InstanceOf()); - var unauthorizedResult = result as UnauthorizedObjectResult; - Assert.That(unauthorizedResult?.Value, Is.EqualTo("Access denied")); + Assert.That(result, Is.InstanceOf()); + var forbidResult = result as ForbidResult; + Assert.That(forbidResult.AuthenticationSchemes.First(), Is.EqualTo("Access denied")); } [Test] diff --git a/Govor.API/Controllers/OnlinePingingController.cs b/Govor.API/Controllers/OnlinePingingController.cs index 9d54551..871e960 100644 --- a/Govor.API/Controllers/OnlinePingingController.cs +++ b/Govor.API/Controllers/OnlinePingingController.cs @@ -43,6 +43,11 @@ public class OnlinePingingController : Controller _logger.LogError(e, e.Message); return BadRequest("User can't be found in our database."); } + catch (UnauthorizedAccessException e) + { + _logger.LogError(e, e.Message); + return Forbid(e.Message); + } catch (Exception e) { _logger.LogError(e, e.Message); From 1fc13000b18ab8495f8640dd4b5824abadd591dc Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Mon, 21 Jul 2025 18:05:30 +0700 Subject: [PATCH 059/101] RefreshControllerTests --- .../Controllers/RefreshControllerTests.cs | 117 ++++++++++++++++++ .../Authentication/AuthController.cs | 8 +- .../Authentication/RefreshController.cs | 9 +- 3 files changed, 126 insertions(+), 8 deletions(-) create mode 100644 Govor.API.Tests/IntegrationTests/Controllers/RefreshControllerTests.cs diff --git a/Govor.API.Tests/IntegrationTests/Controllers/RefreshControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/RefreshControllerTests.cs new file mode 100644 index 0000000..2ab1195 --- /dev/null +++ b/Govor.API.Tests/IntegrationTests/Controllers/RefreshControllerTests.cs @@ -0,0 +1,117 @@ +using AutoFixture; +using Govor.API.Controllers.Authentication; +using Govor.Application.Interfaces.UserSession; +using Govor.Contracts.Requests; +using Govor.Contracts.Responses; +using Microsoft.AspNetCore.Identity.Data; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Govor.API.Tests.IntegrationTests.Controllers; + +[TestFixture] +public class RefreshControllerTests +{ + private Fixture _fixture; + private Mock> _mockLogger; + private Mock _mockSessionRefresher; + private RefreshController _controller; + private RefreshTokenRequest _request; + private RefreshResult _result; + + [SetUp] + public void SetUp() + { + _fixture = new Fixture(); + + _mockLogger = new Mock>(); + _mockSessionRefresher = new Mock(); + + _request = _fixture.Create(); + _result = _fixture.Create(); + + _controller = new RefreshController(_mockLogger.Object, _mockSessionRefresher.Object); + } + + // Tests for Refresh action + [Test] + public async Task Refresh_ValidRequest_ReturnsOkResult() + { + // Arrange + _mockSessionRefresher.Setup(f => f.RefreshTokenAsync(_request.RefreshToken)) + .ReturnsAsync(_result); + + // Act + var result = await _controller.Refresh(_request); + + //Assert + Assert.That(result, Is.InstanceOf()); + var okResult = result as OkObjectResult; + + var response = okResult?.Value as RefreshTokenResponse; + Assert.That(response, Is.Not.Null); + Assert.That(response.AccessToken, Is.EqualTo(_result.accessToken)); + Assert.That(response.RefreshToken, Is.EqualTo(_result.refreshToken)); + } + + [Test] + public async Task Refresh_InvalidModelState_ReturnsBadRequest() + { + // Arrange + _controller.ModelState.AddModelError("Error", "Sample error"); + + // Act + var result = await _controller.Refresh(_request); + + // Assert + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task Refresh_InvalidRefreshToken_ReturnsBadRequest() + { + // Arrange + _request.RefreshToken = string.Empty; + + // Act + var result = await _controller.Refresh(_request); + + // Assert + Assert.That(result, Is.InstanceOf()); + var badRequestResult = result as BadRequestObjectResult; + var response = badRequestResult?.Value; + Assert.That(response, Is.EqualTo("Refresh token cant be empty.")); + } + + [Test] + public async Task Refresh_UnauthorizedAccessException_ReturnsUnauthorizedRequest() + { + // Arrange + _mockSessionRefresher.Setup(f => f.RefreshTokenAsync(_request.RefreshToken)) + .ThrowsAsync(new UnauthorizedAccessException()); + + // Act + var result = await _controller.Refresh(_request); + + // Assert + Assert.That(result, Is.InstanceOf()); + var unauthorized = result as UnauthorizedObjectResult; + var response = unauthorized?.Value; + Assert.That(response, Is.EqualTo("Invalid refresh token")); + } + + [Test] + public async Task Refresh_InvalidOperationException_ReturnsBadRequest() + { + // Arrange + _mockSessionRefresher.Setup(f => f.RefreshTokenAsync(_request.RefreshToken)) + .ThrowsAsync(new InvalidOperationException()); + + // Act + var result = await _controller.Refresh(_request); + + // Assert + Assert.That(result, Is.InstanceOf()); + } +} \ No newline at end of file diff --git a/Govor.API/Controllers/Authentication/AuthController.cs b/Govor.API/Controllers/Authentication/AuthController.cs index 0b97228..7446985 100644 --- a/Govor.API/Controllers/Authentication/AuthController.cs +++ b/Govor.API/Controllers/Authentication/AuthController.cs @@ -46,11 +46,11 @@ public class AuthController : Controller _logger.LogInformation($"Register request for {user.Username} with id {user.Id} processed successfully"); - var token = await _userSession.OpenSessionAsync(user, registrationRequest.DeviceInfo); + var tokens = await _userSession.OpenSessionAsync(user, registrationRequest.DeviceInfo); _logger.LogInformation($"Session for user {user.Username} with id {user.Id} has been opened"); - return Ok(token); + return Ok(tokens); } catch (UserAlreadyExistException ex) { @@ -86,11 +86,11 @@ public class AuthController : Controller var user = await _accountService.LoginAsync(loginRequest.Name, loginRequest.Password); _logger.LogInformation($"Login request for {user.Username} with id {user.Id} processed successfully"); - var token = await _userSession.OpenSessionAsync(user, loginRequest.DeviceInfo); + var tokens = await _userSession.OpenSessionAsync(user, loginRequest.DeviceInfo); _logger.LogInformation($"Session for user {user.Username} with id {user.Id} has been opened"); - return Ok(token); + return Ok(tokens); } catch (UserNotRegisteredException ex) { diff --git a/Govor.API/Controllers/Authentication/RefreshController.cs b/Govor.API/Controllers/Authentication/RefreshController.cs index 1bdc065..426d4bb 100644 --- a/Govor.API/Controllers/Authentication/RefreshController.cs +++ b/Govor.API/Controllers/Authentication/RefreshController.cs @@ -6,6 +6,8 @@ using Microsoft.AspNetCore.Mvc; namespace Govor.API.Controllers.Authentication; +[ApiController] +[AllowAnonymous] [Route("api/auth/token")] public class RefreshController : Controller { @@ -19,8 +21,7 @@ public class RefreshController : Controller } [RequireHttps] - [AllowAnonymous] - [HttpPost("refresh")] + [HttpPost("refresh")] // api/auth/token/refresh public async Task Refresh([FromBody] RefreshTokenRequest refreshRequest) { try @@ -41,12 +42,12 @@ public class RefreshController : Controller } catch (InvalidOperationException ex) { - _logger.LogWarning(ex, "Invalid refresh token"); + _logger.LogWarning(ex, "Invalid refresh token."); return BadRequest(ex.Message); } catch (UnauthorizedAccessException ex) { - _logger.LogWarning(ex, "Refresh token failed"); + _logger.LogWarning(ex, "Refresh token failed."); return Unauthorized("Invalid refresh token"); } catch (Exception ex) From b24649e53a717add16f7bbee91b32a8b4514a36f Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Wed, 23 Jul 2025 13:06:34 +0700 Subject: [PATCH 060/101] Refactor message loading API and add message query support Reworked message loading endpoints and services to support flexible message querying with 'before' and 'after' parameters via a new MessageQuery contract. Updated controller actions, service interfaces, and implementations to use the new query model, and improved error handling. Added integration tests for ChatLoadController and introduced IUserPresenceService interface. Minor fixes and help text improvements in console client commands. --- .../Controllers/ChatLoadControllerTests.cs | 107 +++++++++++++ Govor.API/Controllers/ChatLoadController.cs | 55 +++++-- .../Controllers/OnlinePingingController.cs | 17 +- .../Services/Messages/MessagesLoaderTests.cs | 20 +-- .../Interfaces/IMessagesLoader.cs | 4 +- .../Interfaces/IUserPresenceService.cs | 6 + .../Services/Messages/MessagesLoader.cs | 148 +++++++++++++----- Govor.ConsoleClient/Commands/HelpCommand.cs | 14 +- .../Commands/SendMessageCommand.cs | 2 +- Govor.Contracts/Requests/MessageQuery.cs | 8 + 10 files changed, 300 insertions(+), 81 deletions(-) create mode 100644 Govor.API.Tests/IntegrationTests/Controllers/ChatLoadControllerTests.cs create mode 100644 Govor.Application/Interfaces/IUserPresenceService.cs create mode 100644 Govor.Contracts/Requests/MessageQuery.cs diff --git a/Govor.API.Tests/IntegrationTests/Controllers/ChatLoadControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/ChatLoadControllerTests.cs new file mode 100644 index 0000000..00a0377 --- /dev/null +++ b/Govor.API.Tests/IntegrationTests/Controllers/ChatLoadControllerTests.cs @@ -0,0 +1,107 @@ +using AutoFixture; +using AutoMapper; +using Govor.API.Controllers; +using Govor.Application.Interfaces; +using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.Contracts.Requests; +using Govor.Contracts.Responses; +using Govor.Core.Models.Messages; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Govor.API.Tests.IntegrationTests.Controllers; + +[TestFixture] +public class ChatLoadControllerTests +{ + private Mock _currentUserServiceMock; + private Mock> _loggerMock; + private Mock _messagesLoaderMock; + private Mock _mapperMock; + private Fixture _fixture; + private ChatLoadController _controller; + + private Guid _userId = Guid.NewGuid(); + private Guid _chatId = Guid.NewGuid(); + private Guid _currentId = Guid.NewGuid(); + + + [SetUp] + public void SetUp() + { + _fixture = new Fixture(); + _fixture.Behaviors.OfType().ToList().ForEach(b => _fixture.Behaviors.Remove(b)); + _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); + + _currentUserServiceMock = new Mock(); + _loggerMock = new Mock>(); + _messagesLoaderMock = new Mock(); + _mapperMock = new Mock(); + + _currentUserServiceMock.Setup(u => u.GetCurrentUserId()).Returns(_currentId); + + _controller = new ChatLoadController( + _loggerMock.Object, + _messagesLoaderMock.Object, + _currentUserServiceMock.Object, + _mapperMock.Object); + } + + // Tests for GetChatMessages action + [Test] + public async Task GetChatMessages_ValidRequest_ReturnsOkResult() + { + // Arrange + var random = new Random(); + var messages = _fixture.CreateMany(random.Next(3, 10)).ToList(); + var before = random.Next(1, 10); + var after = random.Next(1, 10); + + var query = new MessageQuery() + { + Before = before, + After = after, + StartMessageId = null + }; + + _messagesLoaderMock.Setup(m => + m.LoadMessagesInChatGroup(_chatId, _currentId, null, before, after)) + .ReturnsAsync(messages); + + var messagesResponse = messages.Select(m => new MessageResponse() + { + Id = m.Id, + SenderId = m.SenderId, + RecipientId = m.RecipientId, + RecipientType = m.RecipientType, + }).ToList(); + + _mapperMock.Setup(f => f.Map>(messages)). + Returns(messagesResponse); + + // Act + var result = await _controller.GetGroupMessages(_chatId, query); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result, Is.InstanceOf()); + + var okResult = (OkObjectResult)result; + var values = okResult.Value as List; + + Assert.That(values, Is.Not.Null); + + Assert.That(values.Select(v => v.Id), + Is.EquivalentTo(messagesResponse.Select(m => m.Id))); + + Assert.That(values.Select(v => v.SenderId), + Is.EquivalentTo(messagesResponse.Select(m => m.SenderId))); + + Assert.That(values.Select(v => v.RecipientId), + Is.EquivalentTo(messagesResponse.Select(m => m.RecipientId))); + + Assert.That(values.Select(v => v.RecipientType), + Is.EquivalentTo(messagesResponse.Select(m => m.RecipientType))); + } +} \ No newline at end of file diff --git a/Govor.API/Controllers/ChatLoadController.cs b/Govor.API/Controllers/ChatLoadController.cs index 36fd42d..fcda9ca 100644 --- a/Govor.API/Controllers/ChatLoadController.cs +++ b/Govor.API/Controllers/ChatLoadController.cs @@ -1,7 +1,9 @@ using AutoMapper; using Govor.Application.Interfaces; using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.Contracts.Requests; using Govor.Contracts.Responses; +using Govor.Data.Repositories.Exceptions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -16,6 +18,7 @@ public class ChatLoadController : Controller private readonly ILogger _logger; private readonly IMessagesLoader _messagesLoader; private readonly IMapper _mapper; + public ChatLoadController( ILogger logger, IMessagesLoader messagesLoader, @@ -29,18 +32,21 @@ public class ChatLoadController : Controller } [HttpGet("group-messages")] - public async Task GetChatMessages( - [FromQuery] Guid chatId, - [FromQuery] Guid? startMessageId, - [FromQuery] int pageSize) + public async Task GetGroupMessages( + Guid chatId, + [FromQuery] MessageQuery query) { try { - var result = await _messagesLoader.LoadLastMessagesInChatGroup( + if (query.Before < 0 || query.After < 0 || query.After + query.Before > 100) + return BadRequest("Values must be non-negative and total must not exceed 100."); + + var result = await _messagesLoader.LoadMessagesInChatGroup( chatId, _currentUser.GetCurrentUserId(), - startMessageId, - pageSize); + query.StartMessageId, + query.Before, + query.After); var response = _mapper.Map>(result); @@ -51,6 +57,16 @@ public class ChatLoadController : Controller _logger.LogWarning(ex.Message); return Forbid(ex.Message); } + catch (NotFoundException ex) + { + _logger.LogWarning(ex, ex.Message); + return BadRequest(ex.Message); + } + catch (ArgumentException ex) + { + _logger.LogWarning(ex, ex.Message); + return BadRequest(ex.Message); + } catch (Exception ex) { _logger.LogError(ex, ex.Message); @@ -60,17 +76,20 @@ public class ChatLoadController : Controller [HttpGet("user-messages")] public async Task GetUserMessages( - [FromQuery] Guid userId, - [FromQuery] Guid? startMessageId, - [FromQuery] int pageSize = 20) + Guid userId, + [FromQuery] MessageQuery query) { try { - var result = await _messagesLoader.LoadLastMessagesInUserChat( + if (query.Before < 0 || query.After < 0 || query.After + query.Before > 100) + return BadRequest("Values must be non-negative and total must not exceed 100."); + + var result = await _messagesLoader.LoadMessagesInUserChat( userId, _currentUser.GetCurrentUserId(), - startMessageId, - pageSize); + query.StartMessageId, + query.Before, + query.After); var response = _mapper.Map>(result); @@ -86,6 +105,16 @@ public class ChatLoadController : Controller _logger.LogWarning(ex.Message); return Forbid(ex.Message); } + catch (NotFoundException ex) + { + _logger.LogWarning(ex, ex.Message); + return BadRequest(ex.Message); + } + catch (ArgumentException ex) + { + _logger.LogWarning(ex, ex.Message); + return BadRequest(ex.Message); + } catch (Exception ex) { _logger.LogError(ex, ex.Message); diff --git a/Govor.API/Controllers/OnlinePingingController.cs b/Govor.API/Controllers/OnlinePingingController.cs index 871e960..3f3a026 100644 --- a/Govor.API/Controllers/OnlinePingingController.cs +++ b/Govor.API/Controllers/OnlinePingingController.cs @@ -13,6 +13,7 @@ public class OnlinePingingController : Controller { private readonly ILogger _logger; private readonly IPingHandlerService _ping; + private readonly IUserPresenceService _presenceService; private readonly ICurrentUserService _currentUserService; public OnlinePingingController(ILogger logger, @@ -51,13 +52,21 @@ public class OnlinePingingController : Controller catch (Exception e) { _logger.LogError(e, e.Message); - return StatusCode(500, new { error = "Failed to send friend request." }); + return StatusCode(500, "Failed to ping."); } } - [HttpGet("is-online")] - public async Task IsOnline(Guid userId) + [HttpGet("status/{userId}")] + public IActionResult GetStatus(Guid userId) { - return BadRequest(); + try + { + return Ok(_presenceService.WhenUserWasOnline(userId)); + } + catch (Exception e) + { + _logger.LogError(e, e.Message); + return StatusCode(500, "Internal server error."); + } } } \ No newline at end of file diff --git a/Govor.Application.Tests/Services/Messages/MessagesLoaderTests.cs b/Govor.Application.Tests/Services/Messages/MessagesLoaderTests.cs index 3c8f76d..4bdf983 100644 --- a/Govor.Application.Tests/Services/Messages/MessagesLoaderTests.cs +++ b/Govor.Application.Tests/Services/Messages/MessagesLoaderTests.cs @@ -56,7 +56,7 @@ public class MessagesLoaderTests await _dbContext.SaveChangesAsync(); // Act - var result = await _loader.LoadLastMessagesInUserChat(_currentUserId, _otherUserId, null); + var result = await _loader.LoadMessagesInUserChat(_currentUserId, _otherUserId, null); // Assert Assert.That(result, Has.Count.EqualTo(1)); @@ -70,7 +70,7 @@ public class MessagesLoaderTests _privateChatsRepoMock.Setup(r => r.GetByMembersAsync(_currentUserId, _otherUserId)) .ReturnsAsync(new PrivateChat { Id = Guid.NewGuid() }); // Act - var result = await _loader.LoadLastMessagesInUserChat(_currentUserId, _otherUserId, null); + var result = await _loader.LoadMessagesInUserChat(_currentUserId, _otherUserId, null); // Assert Assert.That(result, Is.Empty); @@ -81,7 +81,7 @@ public class MessagesLoaderTests { // Act & Assert var ex = Assert.ThrowsAsync(async () => - await _loader.LoadLastMessagesInUserChat(Guid.Empty, _currentUserId, null)); + await _loader.LoadMessagesInUserChat(Guid.Empty, _currentUserId, null)); Assert.That(ex.Message, Does.Contain("User id cannot be empty")); } @@ -94,7 +94,7 @@ public class MessagesLoaderTests // Act & Assert var ex = Assert.ThrowsAsync(async () => - await _loader.LoadLastMessagesInUserChat(_currentUserId, _otherUserId, null)); + await _loader.LoadMessagesInUserChat(_currentUserId, _otherUserId, null)); Assert.That(ex.Message, Is.EqualTo("Private chat not found")); } @@ -116,7 +116,7 @@ public class MessagesLoaderTests await _dbContext.SaveChangesAsync(); // Act - var result = await _loader.LoadLastMessagesInChatGroup(_groupChatId, _currentUserId, null); + var result = await _loader.LoadMessagesInChatGroup(_groupChatId, _currentUserId, null); // Assert Assert.That(result, Has.Count.EqualTo(1)); @@ -127,9 +127,9 @@ public class MessagesLoaderTests { // Act & Assert var ex = Assert.ThrowsAsync(async () => - await _loader.LoadLastMessagesInChatGroup(Guid.Empty, _currentUserId, null)); + await _loader.LoadMessagesInChatGroup(Guid.Empty, _currentUserId, null)); - Assert.That(ex.Message, Does.Contain("Chat id cannot be empty")); + Assert.That(ex.Message, Is.EqualTo("Chat id cannot be empty")); } [Test] @@ -141,9 +141,9 @@ public class MessagesLoaderTests // Act & Assert var ex = Assert.ThrowsAsync(async () => - await _loader.LoadLastMessagesInChatGroup(_groupChatId, _currentUserId, null)); + await _loader.LoadMessagesInChatGroup(_groupChatId, _currentUserId, null)); - Assert.That(ex.Message, Is.EqualTo("You are not in a group.")); + Assert.That(ex.Message, Is.EqualTo("You are not a member of this group.")); } [Test] @@ -154,7 +154,7 @@ public class MessagesLoaderTests .ReturnsAsync(true); // Act - var result = await _loader.LoadLastMessagesInChatGroup(_groupChatId, _currentUserId, null); + var result = await _loader.LoadMessagesInChatGroup(_groupChatId, _currentUserId, null); // Assert Assert.That(result, Is.Empty); diff --git a/Govor.Application/Interfaces/IMessagesLoader.cs b/Govor.Application/Interfaces/IMessagesLoader.cs index cb8880d..54fff2f 100644 --- a/Govor.Application/Interfaces/IMessagesLoader.cs +++ b/Govor.Application/Interfaces/IMessagesLoader.cs @@ -4,6 +4,6 @@ namespace Govor.Application.Interfaces; public interface IMessagesLoader { - Task> LoadLastMessagesInUserChat(Guid userId,Guid currentId, Guid? startMessageId, int pageSize = 20); - Task> LoadLastMessagesInChatGroup(Guid chatId,Guid currentId, Guid? startMessageId, int pageSize = 20); + Task> LoadMessagesInUserChat(Guid userId,Guid currentId, Guid? startMessageId, int before = 20, int after = 2); + Task> LoadMessagesInChatGroup(Guid chatId,Guid currentId, Guid? startMessageId, int before = 20, int after = 2); } \ No newline at end of file diff --git a/Govor.Application/Interfaces/IUserPresenceService.cs b/Govor.Application/Interfaces/IUserPresenceService.cs new file mode 100644 index 0000000..38eea19 --- /dev/null +++ b/Govor.Application/Interfaces/IUserPresenceService.cs @@ -0,0 +1,6 @@ +namespace Govor.Application.Interfaces; + +public interface IUserPresenceService +{ + DateTime WhenUserWasOnline(Guid userId); +} \ No newline at end of file diff --git a/Govor.Application/Services/Messages/MessagesLoader.cs b/Govor.Application/Services/Messages/MessagesLoader.cs index 541a400..872084b 100644 --- a/Govor.Application/Services/Messages/MessagesLoader.cs +++ b/Govor.Application/Services/Messages/MessagesLoader.cs @@ -1,8 +1,6 @@ using Govor.Application.Interfaces; -using Govor.Core.Infrastructure.Extensions; using Govor.Core.Models.Messages; using Govor.Core.Repositories.Groups; -using Govor.Core.Repositories.Messages; using Govor.Core.Repositories.PrivateChats; using Govor.Data; using Govor.Data.Repositories.Exceptions; @@ -26,57 +24,121 @@ public class MessagesLoader : IMessagesLoader _dbContext = dbContext; } - public async Task> LoadLastMessagesInUserChat(Guid userId, Guid currentUser, Guid? startMessageId, int pageSize = 20) + public async Task> LoadMessagesInUserChat( + Guid userId, + Guid currentUser, + Guid? startMessageId, + int before = 20, + int after = 2) { - if(userId == Guid.Empty) + if (userId == Guid.Empty) throw new ArgumentException("User id cannot be empty"); - - if(!_privateChatsRepository.Exist(userId, currentUser)) + + if (!_privateChatsRepository.Exist(userId, currentUser)) throw new InvalidOperationException("Private chat not found"); - - try + + var chat = await _privateChatsRepository.GetByMembersAsync(userId, currentUser); + + var query = _dbContext.Messages + .AsNoTracking() + .Include(m => m.MediaAttachments) + .ThenInclude(m => m.MediaFile) + .Where(m => m.RecipientType == RecipientType.User && + m.RecipientId == chat.Id); + + if (startMessageId is null) { - var chat = await _privateChatsRepository.GetByMembersAsync(userId, currentUser); - - return await _dbContext.Messages - .AsNoTracking() - .Include(m => m.MediaAttachments) - .ThenInclude(m => m.MediaFile) - .AsSplitQuery() - .Where(m => m.RecipientType == RecipientType.User && - m.RecipientId == chat.Id) - .Take(pageSize) - .ToListOrThrowIfEmpty(new NotFoundException("Messages not found")); - } - catch (NotFoundException ex) - { - return new List(); + return await query + .OrderByDescending(m => m.SentAt) + .Take(before) + .ToListAsync(); } + + var startMessage = await _dbContext.Messages.FindAsync(startMessageId.Value); + if (startMessage == null) + throw new NotFoundException("Start message not found"); + + var beforeMessages = await query + .Where(m => m.SentAt < startMessage.SentAt) + .OrderByDescending(m => m.SentAt) + .Take(before) + .ToListAsync(); + + var afterMessages = await query + .Where(m => m.SentAt > startMessage.SentAt) + .OrderBy(m => m.SentAt) + .Take(after) + .ToListAsync(); + + // older -> start -> newer + var result = beforeMessages + .OrderBy(m => m.SentAt) + .Concat(new[] { startMessage }) + .Concat(afterMessages) + .ToList(); + + return result; } - public async Task> LoadLastMessagesInChatGroup(Guid chatId, Guid currentUser, Guid? startMessageId, int pageSize = 20) + + public async Task> LoadMessagesInChatGroup( + Guid chatId, + Guid currentUser, + Guid? startMessageId, + int before = 20, + int after = 2) { - if(chatId == Guid.Empty) + if (chatId == Guid.Empty) throw new ArgumentException("Chat id cannot be empty"); - - if(!await _groupsRepository.IsUserMemberOfGroupAsync(currentUser, chatId)) - throw new UnauthorizedAccessException("You are not in a group."); - - try + + var isMember = await _groupsRepository.IsUserMemberOfGroupAsync(currentUser, chatId); + if (!isMember) + throw new UnauthorizedAccessException("You are not a member of this group."); + + var baseQuery = _dbContext.Messages + .AsNoTracking() + .Include(m => m.MediaAttachments) + .ThenInclude(m => m.MediaFile) + .AsSplitQuery() + .Where(m => m.RecipientType == RecipientType.Group && m.RecipientId == chatId); + + if (startMessageId is null) { - return await _dbContext.Messages - .AsNoTracking() - .Include(m => m.MediaAttachments) - .ThenInclude(m => m.MediaFile) - .AsSplitQuery() - .Where(m => m.RecipientType == RecipientType.Group && - m.RecipientId == chatId) - .Take(pageSize) - .ToListOrThrowIfEmpty(new NotFoundException("Messages not found")); - } - catch (NotFoundException ex) - { - return new List(); + return await baseQuery + .OrderByDescending(m => m.SentAt) + .Take(before) + .OrderBy(m => m.SentAt) + .ToListAsync(); } + + var startMessage = await _dbContext.Messages + .AsNoTracking() + .FirstOrDefaultAsync(m => m.Id == startMessageId.Value && + m.RecipientType == RecipientType.Group && + m.RecipientId == chatId); + + if (startMessage == null) + throw new NotFoundException("Start message not found in this group."); + + var beforeMessages = await baseQuery + .Where(m => m.SentAt < startMessage.SentAt) + .OrderByDescending(m => m.SentAt) + .Take(before) + .ToListAsync(); + + var afterMessages = await baseQuery + .Where(m => m.SentAt > startMessage.SentAt) + .OrderBy(m => m.SentAt) + .Take(after) + .ToListAsync(); + + var result = beforeMessages + .OrderBy(m => m.SentAt) + .Concat(new[] { startMessage }) + .Concat(afterMessages) + .ToList(); + + return result; } + } \ No newline at end of file diff --git a/Govor.ConsoleClient/Commands/HelpCommand.cs b/Govor.ConsoleClient/Commands/HelpCommand.cs index b8cd16f..f0fa612 100644 --- a/Govor.ConsoleClient/Commands/HelpCommand.cs +++ b/Govor.ConsoleClient/Commands/HelpCommand.cs @@ -39,16 +39,14 @@ public class HelpCommand : ICommand else { _logger.Info("Чтобы получить подробную информацию, напишите /help {command}"); - } - - foreach (var command in commands) - { - var name = command.GetType().GetCustomAttribute()?.Path.Replace("/", "").ToLower() - ?? command.GetType().Name.Replace("Command", "").ToLower(); + foreach (var command in commands) + { + var name = command.GetType().GetCustomAttribute()?.Path.Replace("/", "").ToLower() + ?? command.GetType().Name.Replace("Command", "").ToLower(); - _logger.Log($"{name} - {command.ShortHelp()}"); + _logger.Log($"{name} - {command.ShortHelp()}"); + } } - return Task.CompletedTask; } diff --git a/Govor.ConsoleClient/Commands/SendMessageCommand.cs b/Govor.ConsoleClient/Commands/SendMessageCommand.cs index 8aa96db..56d4528 100644 --- a/Govor.ConsoleClient/Commands/SendMessageCommand.cs +++ b/Govor.ConsoleClient/Commands/SendMessageCommand.cs @@ -34,7 +34,7 @@ public class SendMessageCommand : IInteractiveCommand public string LongHelp() { - throw new NotImplementedException(); + return "Отпарвка тестовых сообщений не существующему юзеру 2"; } public string ShortHelp() diff --git a/Govor.Contracts/Requests/MessageQuery.cs b/Govor.Contracts/Requests/MessageQuery.cs new file mode 100644 index 0000000..5dd24f7 --- /dev/null +++ b/Govor.Contracts/Requests/MessageQuery.cs @@ -0,0 +1,8 @@ +namespace Govor.Contracts.Requests; + +public class MessageQuery +{ + public Guid? StartMessageId { get; set; } + public int Before { get; set; } = 20; + public int After { get; set; } = 2; +} From cbff5857e7f725d784286464f3fd6adbebe9891c Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Wed, 23 Jul 2025 15:11:33 +0700 Subject: [PATCH 061/101] Update ChatLoadControllerTests.cs --- .../Controllers/ChatLoadControllerTests.cs | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) diff --git a/Govor.API.Tests/IntegrationTests/Controllers/ChatLoadControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/ChatLoadControllerTests.cs index 00a0377..a8315a0 100644 --- a/Govor.API.Tests/IntegrationTests/Controllers/ChatLoadControllerTests.cs +++ b/Govor.API.Tests/IntegrationTests/Controllers/ChatLoadControllerTests.cs @@ -6,6 +6,7 @@ using Govor.Application.Interfaces.Infrastructure.Extensions; using Govor.Contracts.Requests; using Govor.Contracts.Responses; using Govor.Core.Models.Messages; +using Govor.Data.Repositories.Exceptions; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Moq; @@ -104,4 +105,156 @@ public class ChatLoadControllerTests Assert.That(values.Select(v => v.RecipientType), Is.EquivalentTo(messagesResponse.Select(m => m.RecipientType))); } + + [Test] + public async Task GetGroupMessages_InvalidQuery_ReturnsBadRequest() + { + // Arrange + var query = new MessageQuery { Before = -1, After = 10 }; + + // Act + var result = await _controller.GetGroupMessages(_chatId, query); + + // Assert + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task GetGroupMessages_UnauthorizedAccess_ReturnsForbid() + { + // Arrange + var query = new MessageQuery { Before = 10, After = 10 }; + _messagesLoaderMock.Setup(m => m.LoadMessagesInChatGroup(It.IsAny(), It.IsAny(), null, It.IsAny(), It.IsAny())) + .ThrowsAsync(new UnauthorizedAccessException()); + + // Act + var result = await _controller.GetGroupMessages(_chatId, query); + + // Assert + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task GetGroupMessages_NotFound_ReturnsBadRequest() + { + // Arrange + var query = new MessageQuery { Before = 10, After = 10 }; + _messagesLoaderMock.Setup(m => m.LoadMessagesInChatGroup(It.IsAny(), It.IsAny(), null, It.IsAny(), It.IsAny())) + .ThrowsAsync(new NotFoundException("Chat not found")); + + // Act + var result = await _controller.GetGroupMessages(_chatId, query); + + // Assert + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task GetGroupMessages_ArgumentException_ReturnsBadRequest() + { + // Arrange + var query = new MessageQuery { Before = 10, After = 10 }; + _messagesLoaderMock.Setup(m => m.LoadMessagesInChatGroup(It.IsAny(), It.IsAny(), null, It.IsAny(), It.IsAny())) + .ThrowsAsync(new ArgumentException("Invalid argument")); + + // Act + var result = await _controller.GetGroupMessages(_chatId, query); + + // Assert + Assert.That(result, Is.InstanceOf()); + } + + // GetUserMessages tests + [Test] + public async Task GetUserMessages_ValidRequest_ReturnsOkResult() + { + // Arrange + var messages = _fixture.CreateMany(5).ToList(); + var query = new MessageQuery { Before = 5, After = 5 }; + _messagesLoaderMock.Setup(m => m.LoadMessagesInUserChat(_userId, _currentId, null, 5, 5)) + .ReturnsAsync(messages); + var messageResponses = _fixture.CreateMany(5).ToList(); + _mapperMock.Setup(m => m.Map>(messages)).Returns(messageResponses); + + // Act + var result = await _controller.GetUserMessages(_userId, query); + + // Assert + Assert.That(result, Is.InstanceOf()); + var okResult = result as OkObjectResult; + Assert.That(okResult.Value, Is.EqualTo(messageResponses)); + } + + [Test] + public async Task GetUserMessages_InvalidQuery_ReturnsBadRequest() + { + // Arrange + var query = new MessageQuery { Before = -1, After = 10 }; + + // Act + var result = await _controller.GetUserMessages(_userId, query); + + // Assert + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task GetUserMessages_InvalidOperation_ReturnsBadRequest() + { + // Arrange + var query = new MessageQuery { Before = 10, After = 10 }; + _messagesLoaderMock.Setup(m => m.LoadMessagesInUserChat(It.IsAny(), It.IsAny(), null, It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException()); + + // Act + var result = await _controller.GetUserMessages(_userId, query); + + // Assert + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task GetUserMessages_UnauthorizedAccess_ReturnsForbid() + { + // Arrange + var query = new MessageQuery { Before = 10, After = 10 }; + _messagesLoaderMock.Setup(m => m.LoadMessagesInUserChat(It.IsAny(), It.IsAny(), null, It.IsAny(), It.IsAny())) + .ThrowsAsync(new UnauthorizedAccessException()); + + // Act + var result = await _controller.GetUserMessages(_userId, query); + + // Assert + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task GetUserMessages_NotFound_ReturnsBadRequest() + { + // Arrange + var query = new MessageQuery { Before = 10, After = 10 }; + _messagesLoaderMock.Setup(m => m.LoadMessagesInUserChat(It.IsAny(), It.IsAny(), null, It.IsAny(), It.IsAny())) + .ThrowsAsync(new NotFoundException("User not found")); + + // Act + var result = await _controller.GetUserMessages(_userId, query); + + // Assert + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task GetUserMessages_ArgumentException_ReturnsBadRequest() + { + // Arrange + var query = new MessageQuery { Before = 10, After = 10 }; + _messagesLoaderMock.Setup(m => m.LoadMessagesInUserChat(It.IsAny(), It.IsAny(), null, It.IsAny(), It.IsAny())) + .ThrowsAsync(new ArgumentException("Invalid argument")); + + // Act + var result = await _controller.GetUserMessages(_userId, query); + + // Assert + Assert.That(result, Is.InstanceOf()); + } } \ No newline at end of file From 31fdf4cb37428ac5ee2dd58f7cf7ee0685d4d59c Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Wed, 23 Jul 2025 21:07:29 +0700 Subject: [PATCH 062/101] Add user online presence tracking and notification Introduced interfaces and services for tracking user online status, including IOnlineUserStore, IUserNotificationScopeService, and IUserPresenceReader. Added PresenceHub for real-time presence updates via SignalR. Updated OnlinePingingController to use new services and return online status and last seen. Extended UserDto with IsOnline property. Updated dependency injection and privacy settings enum. Removed obsolete IUserPresenceService. --- .../Controllers/OnlinePingingController.cs | 15 +++- .../ConfigurationProgramExtensions.cs | 7 ++ Govor.API/Hubs/PresenceHub.cs | 83 +++++++++++++++++++ .../UserNotificationScopeServiceTests.cs | 15 ++++ .../Interfaces/IUserPresenceService.cs | 6 -- .../UserOnlineStatus/IOnlineUserStore.cs | 9 ++ .../IUserNotificationScopeService.cs | 6 ++ .../UserOnlineStatus/IUserPresenceReader.cs | 6 ++ .../UserOnlineStatus/OnlineUserStore.cs | 26 ++++++ .../UserNotificationScopeService.cs | 54 ++++++++++++ .../UserOnlineStatus/UserPresenceReader.cs | 11 +++ Govor.Contracts/DTOs/UserDto.cs | 1 + .../Models/Users/PrivacyUserSettings.cs | 3 +- 13 files changed, 231 insertions(+), 11 deletions(-) create mode 100644 Govor.API/Hubs/PresenceHub.cs create mode 100644 Govor.Application.Tests/Services/UserOnlineStatus/UserNotificationScopeServiceTests.cs delete mode 100644 Govor.Application/Interfaces/IUserPresenceService.cs create mode 100644 Govor.Application/Interfaces/UserOnlineStatus/IOnlineUserStore.cs create mode 100644 Govor.Application/Interfaces/UserOnlineStatus/IUserNotificationScopeService.cs create mode 100644 Govor.Application/Interfaces/UserOnlineStatus/IUserPresenceReader.cs create mode 100644 Govor.Application/Services/UserOnlineStatus/OnlineUserStore.cs create mode 100644 Govor.Application/Services/UserOnlineStatus/UserNotificationScopeService.cs create mode 100644 Govor.Application/Services/UserOnlineStatus/UserPresenceReader.cs diff --git a/Govor.API/Controllers/OnlinePingingController.cs b/Govor.API/Controllers/OnlinePingingController.cs index 3f3a026..0a58dfc 100644 --- a/Govor.API/Controllers/OnlinePingingController.cs +++ b/Govor.API/Controllers/OnlinePingingController.cs @@ -1,6 +1,6 @@ using Govor.Application.Interfaces; using Govor.Application.Interfaces.Infrastructure.Extensions; -using Govor.Core.Repositories.Users; +using Govor.Application.Interfaces.UserOnlineStatus; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -13,7 +13,8 @@ public class OnlinePingingController : Controller { private readonly ILogger _logger; private readonly IPingHandlerService _ping; - private readonly IUserPresenceService _presenceService; + private readonly IUserPresenceReader _presenceReader; + private readonly IOnlineUserStore _userOnlineStore; private readonly ICurrentUserService _currentUserService; public OnlinePingingController(ILogger logger, @@ -57,11 +58,17 @@ public class OnlinePingingController : Controller } [HttpGet("status/{userId}")] - public IActionResult GetStatus(Guid userId) + public async Task GetStatus(Guid userId) { try { - return Ok(_presenceService.WhenUserWasOnline(userId)); + var isOnline = _userOnlineStore.IsOnline(userId); + var lastSeen = await _presenceReader.GetLastSeenAsync(userId); + + return Ok(new { + isOnline, + lastSeen + }); } catch (Exception e) { diff --git a/Govor.API/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Extensions/ConfigurationProgramExtensions.cs index d169afb..be0667d 100644 --- a/Govor.API/Extensions/ConfigurationProgramExtensions.cs +++ b/Govor.API/Extensions/ConfigurationProgramExtensions.cs @@ -7,12 +7,14 @@ using Govor.Application.Interfaces.Friends; using Govor.Application.Interfaces.Infrastructure.Extensions; using Govor.Application.Interfaces.Medias; using Govor.Application.Interfaces.Messages; +using Govor.Application.Interfaces.UserOnlineStatus; using Govor.Application.Interfaces.UserSession; using Govor.Application.Services; using Govor.Application.Services.Authentication; using Govor.Application.Services.Friends; using Govor.Application.Services.Medias; using Govor.Application.Services.Messages; +using Govor.Application.Services.UserOnlineStatus; using Govor.Application.Services.UserSessions; using Govor.Core.Infrastructure.Extensions; using Govor.Core.Infrastructure.Validators; @@ -74,6 +76,11 @@ public static class ConfigurationProgramExtensions // UserSession services.AddScoped(); services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + // Auto Mapper services.AddAutoMapper(typeof(MappingProfile)); } diff --git a/Govor.API/Hubs/PresenceHub.cs b/Govor.API/Hubs/PresenceHub.cs new file mode 100644 index 0000000..fc23170 --- /dev/null +++ b/Govor.API/Hubs/PresenceHub.cs @@ -0,0 +1,83 @@ +using Govor.Application.Interfaces; +using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.Application.Interfaces.UserOnlineStatus; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.SignalR; + +namespace Govor.API.Hubs; + +[Authorize(Roles = "Admin, User")] +[Route("hubs/presence")] +public class PresenceHub : Hub +{ + private readonly ILogger _logger; + private readonly IUserNotificationScopeService _notificationScopeService; + private readonly IOnlineUserStore _onlineUserStore; + + public PresenceHub(ILogger logger, IUserNotificationScopeService notificationScopeService, IOnlineUserStore onlineUserStore) + { + _logger = logger; + _notificationScopeService = notificationScopeService; + _onlineUserStore = onlineUserStore; + } + + public override async Task OnConnectedAsync() + { + var userId = GetUserId(); + if (userId == Guid.Empty) + { + _logger.LogWarning("User connected with invalid UserID claim."); + Context.Abort(); + return; + } + + _onlineUserStore.SetOnlineUser(userId); + await Groups.AddToGroupAsync(Context.ConnectionId, userId.ToString()); + + var friends = await _notificationScopeService.GetNotifiedUsers(userId); + + foreach (var recipient in friends) + { + await Clients.Group(recipient.ToString()) + .SendAsync("UserOnline", userId); + } + + await base.OnConnectedAsync(); + } + + public override async Task OnDisconnectedAsync(Exception? exception) + { + var userId = GetUserId(); + if (userId == Guid.Empty) return; + + _onlineUserStore.SetOfflineUser(userId); + + var friends = await _notificationScopeService.GetNotifiedUsers(userId); + + foreach (var recipient in friends) + { + await Clients.Group(recipient.ToString()) + .SendAsync("UserOffline", userId); + } + + await base.OnDisconnectedAsync(exception); + } + + private Guid GetUserId(bool suppressException = false) + { + var userIdClaim = Context.User?.FindFirst("userId")?.Value; + if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) + { + if (!suppressException) + { + _logger.LogError("Could not retrieve sender userId. Claim was: {UserIDClaim}", userIdClaim); + throw new UnauthorizedAccessException("userId claim is missing or invalid."); + } + + return Guid.Empty; + } + + return userId; + } +} \ No newline at end of file diff --git a/Govor.Application.Tests/Services/UserOnlineStatus/UserNotificationScopeServiceTests.cs b/Govor.Application.Tests/Services/UserOnlineStatus/UserNotificationScopeServiceTests.cs new file mode 100644 index 0000000..b4cf4ef --- /dev/null +++ b/Govor.Application.Tests/Services/UserOnlineStatus/UserNotificationScopeServiceTests.cs @@ -0,0 +1,15 @@ +using Govor.Application.Services.UserOnlineStatus; + +namespace Govor.Application.Tests.Services.UserOnlineStatus; + +[TestFixture] +[TestOf(typeof(UserNotificationScopeService))] +public class UserNotificationScopeServiceTests +{ + + [Test] + public void METHOD() + { + + } +} \ No newline at end of file diff --git a/Govor.Application/Interfaces/IUserPresenceService.cs b/Govor.Application/Interfaces/IUserPresenceService.cs deleted file mode 100644 index 38eea19..0000000 --- a/Govor.Application/Interfaces/IUserPresenceService.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Govor.Application.Interfaces; - -public interface IUserPresenceService -{ - DateTime WhenUserWasOnline(Guid userId); -} \ No newline at end of file diff --git a/Govor.Application/Interfaces/UserOnlineStatus/IOnlineUserStore.cs b/Govor.Application/Interfaces/UserOnlineStatus/IOnlineUserStore.cs new file mode 100644 index 0000000..65c4030 --- /dev/null +++ b/Govor.Application/Interfaces/UserOnlineStatus/IOnlineUserStore.cs @@ -0,0 +1,9 @@ +namespace Govor.Application.Interfaces.UserOnlineStatus; + +public interface IOnlineUserStore +{ + void SetOnlineUser(Guid userId); + void SetOfflineUser(Guid userId); + bool IsOnline(Guid userId); + IReadOnlyCollection GetAllOnlineUsers(); +} \ No newline at end of file diff --git a/Govor.Application/Interfaces/UserOnlineStatus/IUserNotificationScopeService.cs b/Govor.Application/Interfaces/UserOnlineStatus/IUserNotificationScopeService.cs new file mode 100644 index 0000000..0ac93a0 --- /dev/null +++ b/Govor.Application/Interfaces/UserOnlineStatus/IUserNotificationScopeService.cs @@ -0,0 +1,6 @@ +namespace Govor.Application.Interfaces.UserOnlineStatus; + +public interface IUserNotificationScopeService +{ + Task> GetNotifiedUsers(Guid userId); +} \ No newline at end of file diff --git a/Govor.Application/Interfaces/UserOnlineStatus/IUserPresenceReader.cs b/Govor.Application/Interfaces/UserOnlineStatus/IUserPresenceReader.cs new file mode 100644 index 0000000..5291ccb --- /dev/null +++ b/Govor.Application/Interfaces/UserOnlineStatus/IUserPresenceReader.cs @@ -0,0 +1,6 @@ +namespace Govor.Application.Interfaces.UserOnlineStatus; + +public interface IUserPresenceReader +{ + Task GetLastSeenAsync(Guid userId); +} \ No newline at end of file diff --git a/Govor.Application/Services/UserOnlineStatus/OnlineUserStore.cs b/Govor.Application/Services/UserOnlineStatus/OnlineUserStore.cs new file mode 100644 index 0000000..a3813b9 --- /dev/null +++ b/Govor.Application/Services/UserOnlineStatus/OnlineUserStore.cs @@ -0,0 +1,26 @@ +using Govor.Application.Interfaces.UserOnlineStatus; + +namespace Govor.Application.Services.UserOnlineStatus; + +public class OnlineUserStore : IOnlineUserStore +{ + public void SetOnlineUser(Guid userId) + { + throw new NotImplementedException(); + } + + public void SetOfflineUser(Guid userId) + { + throw new NotImplementedException(); + } + + public bool IsOnline(Guid userId) + { + throw new NotImplementedException(); + } + + public IReadOnlyCollection GetAllOnlineUsers() + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/Govor.Application/Services/UserOnlineStatus/UserNotificationScopeService.cs b/Govor.Application/Services/UserOnlineStatus/UserNotificationScopeService.cs new file mode 100644 index 0000000..5435f49 --- /dev/null +++ b/Govor.Application/Services/UserOnlineStatus/UserNotificationScopeService.cs @@ -0,0 +1,54 @@ +using Govor.Application.Interfaces.Friends; +using Govor.Application.Interfaces.UserOnlineStatus; +using Govor.Data.Repositories.Exceptions; +using Microsoft.Extensions.Logging; + +namespace Govor.Application.Services.UserOnlineStatus; + +public class UserNotificationScopeService : IUserNotificationScopeService +{ + private readonly ILogger _logger; + private readonly IFriendshipService _friendships; + + public UserNotificationScopeService(ILogger logger, IFriendshipService friendships) + { + _logger = logger; + _friendships = friendships; + } + + public async Task> GetNotifiedUsers(Guid userId) + { + try + { + _logger.LogInformation($"Getting notified users of online/offline action user {userId}"); + var users = await _friendships.GetFriendsAsync(userId); + return users.Select(u => u.Id).ToList(); + } + catch (NotFoundByKeyException ex) + { + _logger.LogError(ex, ex.Message); + throw new InvalidOperationException("User not found"); + } + } + + /*public async Task SetWasOnlineAsync(Guid userId, DateTime when) + { + try + { + _logger.LogInformation("Set was-online for user {UserId}", userId); + var user = await _users.FindByIdAsync(userId); + user.WasOnline = when; + await _users.UpdateAsync(user); + } + catch (NotFoundByKeyException ex) + { + _logger.LogError(ex, ex.Message); + throw new InvalidOperationException("User not found"); + } + catch (UpdateException ex) + { + _logger.LogError(ex, "Failed to set WasOnline for user {UserId} at {Time}", userId, when); + throw new InvalidOperationException("Something went wrong when trying to set was-online for user"); + } + }*/ +} \ No newline at end of file diff --git a/Govor.Application/Services/UserOnlineStatus/UserPresenceReader.cs b/Govor.Application/Services/UserOnlineStatus/UserPresenceReader.cs new file mode 100644 index 0000000..aeab7ab --- /dev/null +++ b/Govor.Application/Services/UserOnlineStatus/UserPresenceReader.cs @@ -0,0 +1,11 @@ +using Govor.Application.Interfaces.UserOnlineStatus; + +namespace Govor.Application.Services.UserOnlineStatus; + +public class UserPresenceReader : IUserPresenceReader +{ + public Task GetLastSeenAsync(Guid userId) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/Govor.Contracts/DTOs/UserDto.cs b/Govor.Contracts/DTOs/UserDto.cs index 06b00cf..c89db9d 100644 --- a/Govor.Contracts/DTOs/UserDto.cs +++ b/Govor.Contracts/DTOs/UserDto.cs @@ -7,4 +7,5 @@ public class UserDto public string Description { get; set; } public DateTime WasOnline { get; set; } public Guid IconId {get; set;} + public bool IsOnline { get; set; } } \ No newline at end of file diff --git a/Govor.Core/Models/Users/PrivacyUserSettings.cs b/Govor.Core/Models/Users/PrivacyUserSettings.cs index 9997c9f..001c38d 100644 --- a/Govor.Core/Models/Users/PrivacyUserSettings.cs +++ b/Govor.Core/Models/Users/PrivacyUserSettings.cs @@ -34,7 +34,8 @@ public enum PrivacyTargetArea { CanSend = 0, CanSeeTimeWas = 1, - CanSeeImage = 2 + CanSeeImage = 2, + CanSendImage = 3, } public enum PrivacyRuleType From be0edb0f94ffc0f2432a4333d9e7ecaea7df3ef9 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Wed, 23 Jul 2025 22:21:51 +0700 Subject: [PATCH 063/101] Refactor user ID access in SignalR hubs and add online user tracking Introduces IHubUserAccessor and its implementation to centralize user ID retrieval from SignalR HubCallerContext, replacing duplicated logic in ChatsHub, FriendsHub, and PresenceHub. Moves extension and mapping files to a Common directory, adds UserToUserDtoMappingAction for online status mapping, and implements OnlineUserStore with tests for tracking online users. Updates dependency injection and test code to use the new abstractions. --- .../SignalR/Helpers/HubUserAccessorTests.cs | 114 ++++++++++++++++++ .../IntegrationTests/Hubs/ChatsHubTests.cs | 6 +- .../IntegrationTests/Hubs/FriendsHubTests.cs | 15 ++- .../Extensions/AddOptionExtensions.cs | 2 +- .../ConfigurationProgramExtensions.cs | 6 +- .../Extensions/ConfigurationSignalR.cs | 2 +- .../ConfiguratorLoggerExtensions.cs | 2 +- .../Mapping}/MappingProfile.cs | 7 +- .../Mapping/UserToUserDtoMappingAction.cs | 21 ++++ .../Common/SignalR/Helpers/HubUserAccessor.cs | 30 +++++ .../SignalR/Helpers/IHubUserAccessor.cs | 8 ++ Govor.API/Hubs/ChatsHub.cs | 34 ++---- Govor.API/Hubs/FriendsHub.cs | 44 +++---- Govor.API/Hubs/PresenceHub.cs | 34 ++---- Govor.API/Program.cs | 2 +- .../UserOnlineStatus/OnlineUserStoreTests.cs | 82 +++++++++++++ .../UserOnlineStatus/OnlineUserStore.cs | 13 +- 17 files changed, 326 insertions(+), 96 deletions(-) create mode 100644 Govor.API.Tests/Common/SignalR/Helpers/HubUserAccessorTests.cs rename Govor.API/{ => Common}/Extensions/AddOptionExtensions.cs (91%) rename Govor.API/{ => Common}/Extensions/ConfigurationProgramExtensions.cs (97%) rename Govor.API/{ => Common}/Extensions/ConfigurationSignalR.cs (88%) rename Govor.API/{ => Common}/Extensions/ConfiguratorLoggerExtensions.cs (92%) rename Govor.API/{Extensions => Common/Mapping}/MappingProfile.cs (75%) create mode 100644 Govor.API/Common/Mapping/UserToUserDtoMappingAction.cs create mode 100644 Govor.API/Common/SignalR/Helpers/HubUserAccessor.cs create mode 100644 Govor.API/Common/SignalR/Helpers/IHubUserAccessor.cs create mode 100644 Govor.Application.Tests/Services/UserOnlineStatus/OnlineUserStoreTests.cs diff --git a/Govor.API.Tests/Common/SignalR/Helpers/HubUserAccessorTests.cs b/Govor.API.Tests/Common/SignalR/Helpers/HubUserAccessorTests.cs new file mode 100644 index 0000000..698999f --- /dev/null +++ b/Govor.API.Tests/Common/SignalR/Helpers/HubUserAccessorTests.cs @@ -0,0 +1,114 @@ +using System.Security.Claims; +using Govor.API.Common.SignalR.Helpers; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Govor.API.Tests.Common.SignalR.Helpers; + +[TestFixture] +[TestOf(typeof(HubUserAccessor))] +public class HubUserAccessorTests +{ + private HubUserAccessor _accessor; + private Mock> _loggerMock; + + [SetUp] + public void SetUp() + { + _loggerMock = new Mock>(); + _accessor = new HubUserAccessor(_loggerMock.Object); + } + + private HubCallerContext CreateContextWithClaims(params Claim[] claims) + { + var principal = new ClaimsPrincipal(new ClaimsIdentity(claims)); + var contextMock = new Mock(); + contextMock.Setup(c => c.User).Returns(principal); + return contextMock.Object; + } + + [Test] + public void GetUserId_ValidClaim_ReturnsGuid() + { + // Arrange + var expectedGuid = Guid.NewGuid(); + var context = CreateContextWithClaims(new Claim("userId", expectedGuid.ToString())); + + // Act + var result = _accessor.GetUserId(context); + + // Assert + Assert.That(expectedGuid, Is.EqualTo(result)); + } + + [Test] + public void GetUserId_InvalidClaim_ThrowsException_WhenNotSuppressed() + { + // Arrange + var context = CreateContextWithClaims(new Claim("userId", "not-a-guid")); + + // Act & Assert + Assert.Throws(() => _accessor.GetUserId(context, suppressException: false)); + } + + [Test] + public void GetUserId_InvalidClaim_ReturnsEmptyGuid_WhenSuppressed() + { + // Arrange + var context = CreateContextWithClaims(new Claim("userId", "not-a-guid")); + + // Act + var result = _accessor.GetUserId(context, suppressException: true); + + // Assert + Assert.That(Guid.Empty, Is.EqualTo(result)); + } + + [Test] + public void GetUserId_NoClaim_ThrowsException_WhenNotSuppressed() + { + // Arrange + var context = CreateContextWithClaims(); + + // Act & Assert + Assert.Throws(() => _accessor.GetUserId(context, suppressException: false)); + } + + [Test] + public void GetUserId_NoClaim_ReturnsEmptyGuid_WhenSuppressed() + { + // Arrange + var context = CreateContextWithClaims(); + + // Act + var result = _accessor.GetUserId(context, suppressException: true); + + // Assert + Assert.That(Guid.Empty, Is.EqualTo(result)); + } + + [Test] + public void GetUserId_UserIsNull_ReturnsEmptyGuid_WhenSuppressed() + { + // Arrange + var contextMock = new Mock(); + contextMock.Setup(c => c.User).Returns((ClaimsPrincipal?)null); + + // Act + var result = _accessor.GetUserId(contextMock.Object, suppressException: true); + + // Assert + Assert.That(Guid.Empty, Is.EqualTo(result)); + } + + [Test] + public void GetUserId_UserIsNull_ThrowsException_WhenNotSuppressed() + { + // Arrange + var contextMock = new Mock(); + contextMock.Setup(c => c.User).Returns((ClaimsPrincipal?)null); + // Act & Assert + Assert.Throws(() => _accessor.GetUserId(contextMock.Object, suppressException: false)); + } +} \ No newline at end of file diff --git a/Govor.API.Tests/IntegrationTests/Hubs/ChatsHubTests.cs b/Govor.API.Tests/IntegrationTests/Hubs/ChatsHubTests.cs index 8b4264c..a57b78c 100644 --- a/Govor.API.Tests/IntegrationTests/Hubs/ChatsHubTests.cs +++ b/Govor.API.Tests/IntegrationTests/Hubs/ChatsHubTests.cs @@ -1,4 +1,5 @@ using AutoFixture; +using Govor.API.Common.SignalR.Helpers; using Govor.API.Hubs; using Govor.Application.Interfaces; using Govor.Application.Interfaces.Messages; @@ -13,6 +14,7 @@ public class ChatsHubTests private Mock> _loggerMock; private Mock _messageServiceMock; private Mock _userGroupsServiceMock; + private Mock _hubUserAccessorMock; private Fixture _fixture; private ChatsHub _chatsHub; @@ -26,11 +28,13 @@ public class ChatsHubTests _messageServiceMock = new Mock(); _userGroupsServiceMock = new Mock(); _loggerMock = new Mock>(); + _hubUserAccessorMock = new Mock(); _chatsHub = new ChatsHub( _loggerMock.Object, _messageServiceMock.Object, - _userGroupsServiceMock.Object + _userGroupsServiceMock.Object, + _hubUserAccessorMock.Object ); } diff --git a/Govor.API.Tests/IntegrationTests/Hubs/FriendsHubTests.cs b/Govor.API.Tests/IntegrationTests/Hubs/FriendsHubTests.cs index 57010f7..ce1963c 100644 --- a/Govor.API.Tests/IntegrationTests/Hubs/FriendsHubTests.cs +++ b/Govor.API.Tests/IntegrationTests/Hubs/FriendsHubTests.cs @@ -1,5 +1,6 @@ using AutoFixture; using AutoMapper; +using Govor.API.Common.SignalR.Helpers; using Govor.API.Hubs; using Govor.Application.Exceptions.FriendsService; using Govor.Application.Interfaces.Friends; @@ -17,7 +18,7 @@ namespace Govor.API.Tests.IntegrationTests.Hubs; public class FriendsHubTests { private Mock _friendRequestServiceMock = null!; - private Mock _currentUserServiceMock = null!; + private Mock _currentUserServiceMock = null!; private Mock _clientsMock = null!; private Mock _clientProxyMock = null!; private Mock> _loggerMock = null!; @@ -35,19 +36,23 @@ public class FriendsHubTests _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); _friendRequestServiceMock = new Mock(); - _currentUserServiceMock = new Mock(); + _currentUserServiceMock = new Mock(); _clientsMock = new Mock(); _clientProxyMock = new Mock(); _loggerMock = new Mock>(); _mapperMock = new Mock(); - _currentUserServiceMock.Setup(x => x.GetCurrentUserId()).Returns(_userId); + _currentUserServiceMock.Setup(x => x.GetUserId( + It.IsAny(), + It.IsAny())) + .Returns(_userId); + _clientsMock.Setup(c => c.Group(It.IsAny())).Returns(_clientProxyMock.Object); _hub = new FriendsHub( + _loggerMock.Object, _friendRequestServiceMock.Object, _currentUserServiceMock.Object, - _loggerMock.Object, _mapperMock.Object) { Clients = _clientsMock.Object @@ -129,7 +134,7 @@ public class FriendsHubTests public async Task SendRequest_ShouldReturnUnauthorized_WhenCurrentUserIsNotAuthenticated() { // Arrange - _currentUserServiceMock.Setup(x => x.GetCurrentUserId()) + _currentUserServiceMock.Setup(x => x.GetUserId(It.IsAny(), It.IsAny())) .Throws(new UnauthorizedAccessException("userId claim is missing or invalid")); // Act diff --git a/Govor.API/Extensions/AddOptionExtensions.cs b/Govor.API/Common/Extensions/AddOptionExtensions.cs similarity index 91% rename from Govor.API/Extensions/AddOptionExtensions.cs rename to Govor.API/Common/Extensions/AddOptionExtensions.cs index aa0951c..060de2b 100644 --- a/Govor.API/Extensions/AddOptionExtensions.cs +++ b/Govor.API/Common/Extensions/AddOptionExtensions.cs @@ -1,6 +1,6 @@ using Govor.Application.Services.Authentication; -namespace Govor.API.Extensions; +namespace Govor.API.Common.Extensions; public static class AddOptionExtensions { diff --git a/Govor.API/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs similarity index 97% rename from Govor.API/Extensions/ConfigurationProgramExtensions.cs rename to Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs index be0667d..6a1602a 100644 --- a/Govor.API/Extensions/ConfigurationProgramExtensions.cs +++ b/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs @@ -1,3 +1,5 @@ +using Govor.API.Common.Mapping; +using Govor.API.Common.SignalR.Helpers; using Govor.Application.Infrastructure.AdminsStuff; using Govor.Application.Infrastructure.Extensions; using Govor.Application.Infrastructure.Validators; @@ -34,7 +36,7 @@ using Govor.Data; using Govor.Data.Repositories; using Microsoft.EntityFrameworkCore; -namespace Govor.API.Extensions; +namespace Govor.API.Common.Extensions; public static class ConfigurationProgramExtensions { @@ -83,6 +85,8 @@ public static class ConfigurationProgramExtensions // Auto Mapper services.AddAutoMapper(typeof(MappingProfile)); + + services.AddScoped(); } public static void AddRepositories(this IServiceCollection services) diff --git a/Govor.API/Extensions/ConfigurationSignalR.cs b/Govor.API/Common/Extensions/ConfigurationSignalR.cs similarity index 88% rename from Govor.API/Extensions/ConfigurationSignalR.cs rename to Govor.API/Common/Extensions/ConfigurationSignalR.cs index 69cbe0d..935eb6c 100644 --- a/Govor.API/Extensions/ConfigurationSignalR.cs +++ b/Govor.API/Common/Extensions/ConfigurationSignalR.cs @@ -1,7 +1,7 @@ using Govor.API.Filters; using Microsoft.AspNetCore.SignalR; -namespace Govor.API.Extensions; +namespace Govor.API.Common.Extensions; public static class ConfigurationSignalR { diff --git a/Govor.API/Extensions/ConfiguratorLoggerExtensions.cs b/Govor.API/Common/Extensions/ConfiguratorLoggerExtensions.cs similarity index 92% rename from Govor.API/Extensions/ConfiguratorLoggerExtensions.cs rename to Govor.API/Common/Extensions/ConfiguratorLoggerExtensions.cs index 1ef16b4..a8ccddc 100644 --- a/Govor.API/Extensions/ConfiguratorLoggerExtensions.cs +++ b/Govor.API/Common/Extensions/ConfiguratorLoggerExtensions.cs @@ -1,6 +1,6 @@ using Serilog; -namespace Govor.API.Extensions; +namespace Govor.API.Common.Extensions; public static class ConfiguratorLoggerExtensions { diff --git a/Govor.API/Extensions/MappingProfile.cs b/Govor.API/Common/Mapping/MappingProfile.cs similarity index 75% rename from Govor.API/Extensions/MappingProfile.cs rename to Govor.API/Common/Mapping/MappingProfile.cs index 69045d6..5b15e9e 100644 --- a/Govor.API/Extensions/MappingProfile.cs +++ b/Govor.API/Common/Mapping/MappingProfile.cs @@ -1,11 +1,12 @@ using AutoMapper; +using Govor.API.Extensions.Mapping; using Govor.Contracts.DTOs; using Govor.Contracts.Responses; using Govor.Core.Models; using Govor.Core.Models.Messages; using Govor.Core.Models.Users; -namespace Govor.API.Extensions; +namespace Govor.API.Common.Mapping; public class MappingProfile : Profile { @@ -16,7 +17,9 @@ public class MappingProfile : Profile CreateMap(); CreateMap(); - CreateMap(); + CreateMap() + .AfterMap(); + CreateMap(); } } \ No newline at end of file diff --git a/Govor.API/Common/Mapping/UserToUserDtoMappingAction.cs b/Govor.API/Common/Mapping/UserToUserDtoMappingAction.cs new file mode 100644 index 0000000..c915fb3 --- /dev/null +++ b/Govor.API/Common/Mapping/UserToUserDtoMappingAction.cs @@ -0,0 +1,21 @@ +using AutoMapper; +using Govor.Application.Interfaces.UserOnlineStatus; +using Govor.Contracts.DTOs; +using Govor.Core.Models.Users; + +namespace Govor.API.Extensions.Mapping; + +public class UserToUserDtoMappingAction : IMappingAction +{ + private readonly IOnlineUserStore _onlineUserStore; + + public UserToUserDtoMappingAction(IOnlineUserStore onlineUserStore) + { + _onlineUserStore = onlineUserStore; + } + + public void Process(User source, UserDto destination, ResolutionContext context) + { + destination.IsOnline = _onlineUserStore.IsOnline(source.Id); + } +} \ No newline at end of file diff --git a/Govor.API/Common/SignalR/Helpers/HubUserAccessor.cs b/Govor.API/Common/SignalR/Helpers/HubUserAccessor.cs new file mode 100644 index 0000000..2cf2ece --- /dev/null +++ b/Govor.API/Common/SignalR/Helpers/HubUserAccessor.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.SignalR; + +namespace Govor.API.Common.SignalR.Helpers; + +public class HubUserAccessor : IHubUserAccessor +{ + private readonly ILogger _logger; + + public HubUserAccessor(ILogger logger) + { + _logger = logger; + } + + public Guid GetUserId(HubCallerContext context, bool suppressException = false) + { + var userIdClaim = context.User?.FindFirst("userId")?.Value; + if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) + { + if (!suppressException) + { + _logger.LogError("Could not retrieve sender userId. Claim was: {UserIDClaim}", userIdClaim); + throw new UnauthorizedAccessException("userID claim is missing or invalid."); + } + + return Guid.Empty; + } + + return userId; + } +} \ No newline at end of file diff --git a/Govor.API/Common/SignalR/Helpers/IHubUserAccessor.cs b/Govor.API/Common/SignalR/Helpers/IHubUserAccessor.cs new file mode 100644 index 0000000..11f7604 --- /dev/null +++ b/Govor.API/Common/SignalR/Helpers/IHubUserAccessor.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.SignalR; + +namespace Govor.API.Common.SignalR.Helpers; + +public interface IHubUserAccessor +{ + Guid GetUserId(HubCallerContext context, bool suppressException = false); +} diff --git a/Govor.API/Hubs/ChatsHub.cs b/Govor.API/Hubs/ChatsHub.cs index 2f50ea8..127460f 100644 --- a/Govor.API/Hubs/ChatsHub.cs +++ b/Govor.API/Hubs/ChatsHub.cs @@ -1,3 +1,4 @@ +using Govor.API.Common.SignalR.Helpers; using Govor.Application.Exceptions.VerifyFriendship; using Govor.Application.Interfaces; using Govor.Application.Interfaces.Messages; @@ -16,17 +17,19 @@ public class ChatsHub : Hub private readonly ILogger _logger; private readonly IMessageCommandService _messageCommandService; private readonly IUserGroupsService _userService; + private readonly IHubUserAccessor _userAccessor; - public ChatsHub(ILogger logger, IMessageCommandService messageCommandService, IUserGroupsService userService) + public ChatsHub(ILogger logger, IMessageCommandService messageCommandService, IUserGroupsService userService, IHubUserAccessor userAccessor) { _logger = logger; _messageCommandService = messageCommandService; _userService = userService; + _userAccessor = userAccessor; } public override async Task OnConnectedAsync() { - var userId = GetUserId(); + var userId = _userAccessor.GetUserId(Context); if (userId == Guid.Empty) { _logger.LogWarning("User connected with invalid UserID claim."); @@ -50,8 +53,7 @@ public class ChatsHub : Hub public override async Task OnDisconnectedAsync(Exception? exception) { - var userId = - GetUserId(suppressException: true); + var userId = _userAccessor.GetUserId(Context, true); if (userId != Guid.Empty) { await Groups.RemoveFromGroupAsync(Context.ConnectionId, userId.ToString()); @@ -85,7 +87,7 @@ public class ChatsHub : Hub public async Task> Send(MessageRequest request) { - var senderId = GetUserId(); + var senderId= _userAccessor.GetUserId(Context); if (string.IsNullOrWhiteSpace(request.EncryptedContent) && (request.MediaAttachments == null || !request.MediaAttachments.Any())) @@ -144,7 +146,7 @@ public class ChatsHub : Hub public async Task> Remove(RemoveMessageRequest request) { - var removerId = GetUserId(); + var removerId = _userAccessor.GetUserId(Context); _logger.LogInformation("Removing message {MessageId} by user {RemoverId}", request.MessageId, removerId); try @@ -184,7 +186,7 @@ public class ChatsHub : Hub public async Task> Edit(EditMessageRequest request) { - var editor = GetUserId(); + var editor = _userAccessor.GetUserId(Context); _logger.LogInformation("Editing message {MessageId} by user {EditorId}", request.MessageId, editor); var editMessageParam = new EditMessage(editor, @@ -303,22 +305,4 @@ public class ChatsHub : Hub _logger.LogWarning(ex, "{Msg}: {UserId} -> {TargetId}", msg, userId, targetId); return HubResult.NotFound("Message not found."); } - - - private Guid GetUserId(bool suppressException = false) - { - var userIdClaim = Context.User?.FindFirst("userId")?.Value; - if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) - { - if (!suppressException) - { - _logger.LogError("Could not retrieve sender userId. Claim was: {UserIDClaim}", userIdClaim); - throw new UnauthorizedAccessException("userID claim is missing or invalid."); - } - - return Guid.Empty; - } - - return userId; - } } \ No newline at end of file diff --git a/Govor.API/Hubs/FriendsHub.cs b/Govor.API/Hubs/FriendsHub.cs index 3fa025e..7aa9564 100644 --- a/Govor.API/Hubs/FriendsHub.cs +++ b/Govor.API/Hubs/FriendsHub.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using AutoMapper; +using Govor.API.Common.SignalR.Helpers; using Govor.Application.Exceptions.FriendsService; using Govor.Application.Interfaces.Friends; using Govor.Application.Interfaces.Infrastructure.Extensions; @@ -13,23 +14,24 @@ public class FriendsHub : Hub { private readonly ILogger _logger; private readonly IFriendRequestCommandService _friendRequestService; - private readonly ICurrentUserService _currentUserService; + private readonly IHubUserAccessor _userAccessor; private readonly IMapper _mapper; - - public FriendsHub(IFriendRequestCommandService friendRequestService, - ICurrentUserService currentUserService, + + public FriendsHub( ILogger logger, + IFriendRequestCommandService friendRequestService, + IHubUserAccessor userAccessor, IMapper mapper) { - _friendRequestService = friendRequestService; - _currentUserService = currentUserService; _logger = logger; + _friendRequestService = friendRequestService; + _userAccessor = userAccessor; _mapper = mapper; } - + public override async Task OnConnectedAsync() { - var userId = GetUserId(); + var userId = _userAccessor.GetUserId(Context); if (userId == Guid.Empty) { _logger.LogWarning("User connected with invalid UserID claim."); @@ -46,8 +48,7 @@ public class FriendsHub : Hub } public override async Task OnDisconnectedAsync(Exception? exception) { - var userId = - GetUserId(suppressException: true); + var userId = _userAccessor.GetUserId(Context, true); if (userId != Guid.Empty) { // Remove user from their own group @@ -76,7 +77,7 @@ public class FriendsHub : Hub { try { - var userId = _currentUserService.GetCurrentUserId(); + var userId = _userAccessor.GetUserId(Context); var friendship = await _friendRequestService.SendAsync(userId, targetUserId); await Clients.Group(targetUserId.ToString()) @@ -111,7 +112,7 @@ public class FriendsHub : Hub { try { - var userId = _currentUserService.GetCurrentUserId(); + var userId = _userAccessor.GetUserId(Context); var friendship = await _friendRequestService.AcceptAsync(friendshipId, userId); await Clients.Group(userId.ToString()) .SendAsync("FriendRequestAccepted", _mapper.Map(friendship)); @@ -140,7 +141,7 @@ public class FriendsHub : Hub { try { - var userId = _currentUserService.GetCurrentUserId(); + var userId = _userAccessor.GetUserId(Context); var friendship = await _friendRequestService.RejectAsync(friendshipId, userId); await Clients.Group(userId.ToString()) .SendAsync("FriendRequestRejected", _mapper.Map(friendship)); @@ -164,21 +165,4 @@ public class FriendsHub : Hub return HubResult.Error("Unexpected error! Please try later!"); } } - - private Guid GetUserId(bool suppressException = false) - { - var userIdClaim = Context.User?.FindFirst("userId")?.Value; - if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) - { - if (!suppressException) - { - _logger.LogError("Could not retrieve sender userId. Claim was: {UserIDClaim}", userIdClaim); - throw new UnauthorizedAccessException("userID claim is missing or invalid."); - } - - return Guid.Empty; - } - - return userId; - } } \ No newline at end of file diff --git a/Govor.API/Hubs/PresenceHub.cs b/Govor.API/Hubs/PresenceHub.cs index fc23170..76843bc 100644 --- a/Govor.API/Hubs/PresenceHub.cs +++ b/Govor.API/Hubs/PresenceHub.cs @@ -1,5 +1,4 @@ -using Govor.Application.Interfaces; -using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.API.Common.SignalR.Helpers; using Govor.Application.Interfaces.UserOnlineStatus; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Components; @@ -14,17 +13,23 @@ public class PresenceHub : Hub private readonly ILogger _logger; private readonly IUserNotificationScopeService _notificationScopeService; private readonly IOnlineUserStore _onlineUserStore; - - public PresenceHub(ILogger logger, IUserNotificationScopeService notificationScopeService, IOnlineUserStore onlineUserStore) + private readonly IHubUserAccessor _userAccessor; + + public PresenceHub( + ILogger logger, + IUserNotificationScopeService notificationScopeService, + IOnlineUserStore onlineUserStore, + IHubUserAccessor userAccessor) { _logger = logger; _notificationScopeService = notificationScopeService; _onlineUserStore = onlineUserStore; + _userAccessor = userAccessor; } public override async Task OnConnectedAsync() { - var userId = GetUserId(); + var userId = _userAccessor.GetUserId(Context); if (userId == Guid.Empty) { _logger.LogWarning("User connected with invalid UserID claim."); @@ -48,7 +53,7 @@ public class PresenceHub : Hub public override async Task OnDisconnectedAsync(Exception? exception) { - var userId = GetUserId(); + var userId = _userAccessor.GetUserId(Context, true); if (userId == Guid.Empty) return; _onlineUserStore.SetOfflineUser(userId); @@ -63,21 +68,4 @@ public class PresenceHub : Hub await base.OnDisconnectedAsync(exception); } - - private Guid GetUserId(bool suppressException = false) - { - var userIdClaim = Context.User?.FindFirst("userId")?.Value; - if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) - { - if (!suppressException) - { - _logger.LogError("Could not retrieve sender userId. Claim was: {UserIDClaim}", userIdClaim); - throw new UnauthorizedAccessException("userId claim is missing or invalid."); - } - - return Guid.Empty; - } - - return userId; - } } \ No newline at end of file diff --git a/Govor.API/Program.cs b/Govor.API/Program.cs index 2e1886e..935a705 100644 --- a/Govor.API/Program.cs +++ b/Govor.API/Program.cs @@ -1,5 +1,5 @@ using System.Text; -using Govor.API.Extensions; +using Govor.API.Common.Extensions; using Govor.API.Hubs; using Govor.Application.Services.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; diff --git a/Govor.Application.Tests/Services/UserOnlineStatus/OnlineUserStoreTests.cs b/Govor.Application.Tests/Services/UserOnlineStatus/OnlineUserStoreTests.cs new file mode 100644 index 0000000..f97a677 --- /dev/null +++ b/Govor.Application.Tests/Services/UserOnlineStatus/OnlineUserStoreTests.cs @@ -0,0 +1,82 @@ +using Govor.Application.Interfaces.UserOnlineStatus; +using Govor.Application.Services.UserOnlineStatus; + +namespace Govor.Application.Tests.Services.UserOnlineStatus; + +[TestFixture] +public class OnlineUserStoreTests +{ + private IOnlineUserStore _store; + private Guid _userId1; + private Guid _userId2; + + [SetUp] + public void Setup() + { + _store = new OnlineUserStore(); + _userId1 = Guid.NewGuid(); + _userId2 = Guid.NewGuid(); + } + + [Test] + public void SetOnlineUser_UserIsMarkedOnline() + { + // Act + _store.SetOnlineUser(_userId1); + + // Assert + Assert.That(_store.IsOnline(_userId1), Is.True); + } + + [Test] + public void SetOfflineUser_UserIsNoLongerOnline() + { + // Act + _store.SetOnlineUser(_userId1); + _store.SetOfflineUser(_userId1); + + // Assert + Assert.That(_store.IsOnline(_userId1), Is.False); + } + + [Test] + public void IsOnline_ReturnsFalse_ForUnknownUser() + { + // Act & Assert + Assert.That(_store.IsOnline(Guid.NewGuid()), Is.False); + } + + [Test] + public void GetAllOnlineUsers_ReturnsAllCurrentlyOnlineUsers() + { + // Arrange + _store.SetOnlineUser(_userId1); + _store.SetOnlineUser(_userId2); + + // Act + var onlineUsers = _store.GetAllOnlineUsers(); + + // Assert + Assert.That(onlineUsers, Is.EquivalentTo(new[] { _userId1, _userId2 })); + } + + [Test] + public void SetOnlineUser_Twice_DoesNotThrow() + { + // Act & Assert + Assert.DoesNotThrow(() => + { + _store.SetOnlineUser(_userId1); + _store.SetOnlineUser(_userId1); + }); + + Assert.That(_store.IsOnline(_userId1), Is.True); + } + + [Test] + public void SetOfflineUser_ForUnknownUser_DoesNotThrow() + { + // Act & Assert + Assert.DoesNotThrow(() => _store.SetOfflineUser(Guid.NewGuid())); + } +} \ No newline at end of file diff --git a/Govor.Application/Services/UserOnlineStatus/OnlineUserStore.cs b/Govor.Application/Services/UserOnlineStatus/OnlineUserStore.cs index a3813b9..2100472 100644 --- a/Govor.Application/Services/UserOnlineStatus/OnlineUserStore.cs +++ b/Govor.Application/Services/UserOnlineStatus/OnlineUserStore.cs @@ -1,26 +1,29 @@ +using System.Collections.Concurrent; using Govor.Application.Interfaces.UserOnlineStatus; namespace Govor.Application.Services.UserOnlineStatus; public class OnlineUserStore : IOnlineUserStore { + private readonly ConcurrentDictionary _onlineUsers = new(); + public void SetOnlineUser(Guid userId) { - throw new NotImplementedException(); + _onlineUsers[userId] = DateTime.UtcNow; } public void SetOfflineUser(Guid userId) { - throw new NotImplementedException(); + _onlineUsers.TryRemove(userId, out _); } public bool IsOnline(Guid userId) { - throw new NotImplementedException(); + return _onlineUsers.ContainsKey(userId); } public IReadOnlyCollection GetAllOnlineUsers() { - throw new NotImplementedException(); + return _onlineUsers.Keys.ToList(); } -} \ No newline at end of file +} From e2771590225f1863072f2b1b5fa03d8cfada48c9 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Wed, 23 Jul 2025 22:41:46 +0700 Subject: [PATCH 064/101] UserNotificationScopeServiceTests --- Govor.API/Hubs/FriendsHub.cs | 2 - .../UserNotificationScopeServiceTests.cs | 59 ++++++++++++++++++- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/Govor.API/Hubs/FriendsHub.cs b/Govor.API/Hubs/FriendsHub.cs index 7aa9564..50b8e2a 100644 --- a/Govor.API/Hubs/FriendsHub.cs +++ b/Govor.API/Hubs/FriendsHub.cs @@ -1,9 +1,7 @@ -using System.ComponentModel.DataAnnotations; using AutoMapper; using Govor.API.Common.SignalR.Helpers; using Govor.Application.Exceptions.FriendsService; using Govor.Application.Interfaces.Friends; -using Govor.Application.Interfaces.Infrastructure.Extensions; using Govor.Contracts.DTOs; using Govor.Contracts.Responses.SignalR; using Microsoft.AspNetCore.SignalR; diff --git a/Govor.Application.Tests/Services/UserOnlineStatus/UserNotificationScopeServiceTests.cs b/Govor.Application.Tests/Services/UserOnlineStatus/UserNotificationScopeServiceTests.cs index b4cf4ef..1eb0eb7 100644 --- a/Govor.Application.Tests/Services/UserOnlineStatus/UserNotificationScopeServiceTests.cs +++ b/Govor.Application.Tests/Services/UserOnlineStatus/UserNotificationScopeServiceTests.cs @@ -1,4 +1,12 @@ +using AutoFixture; +using Govor.Application.Interfaces.Friends; +using Govor.Application.Interfaces.UserOnlineStatus; using Govor.Application.Services.UserOnlineStatus; +using Govor.Core.Models; +using Govor.Core.Models.Users; +using Govor.Data.Repositories.Exceptions; +using Microsoft.Extensions.Logging; +using Moq; namespace Govor.Application.Tests.Services.UserOnlineStatus; @@ -6,10 +14,55 @@ namespace Govor.Application.Tests.Services.UserOnlineStatus; [TestOf(typeof(UserNotificationScopeService))] public class UserNotificationScopeServiceTests { + private Mock> _mockLogger; + private Mock _mockFriendshipService; + private IUserNotificationScopeService _service; + private Fixture _fixture; + private Guid _userId; + + + [SetUp] + public void SetUp() + { + _fixture = new Fixture(); + _fixture.Behaviors.OfType().ToList().ForEach(b => _fixture.Behaviors.Remove(b)); + _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); + + _userId = Guid.NewGuid(); + + _mockLogger = new Mock>(); + _mockFriendshipService = new Mock(); + + _service = new UserNotificationScopeService(_mockLogger.Object, _mockFriendshipService.Object); + } + + [Test] + public async Task GetNotifiedUsers_ReturnsNotifiedUsers() + { + // Arrange + var random = new Random(); + var users = _fixture.CreateMany(random.Next(3, 10)).ToList(); + + _mockFriendshipService.Setup(f => f.GetFriendsAsync(_userId)) + .ReturnsAsync(users); + + // Act + var result = await _service.GetNotifiedUsers(_userId); + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.Count, Is.EqualTo(users.Count)); + Assert.That(result, Is.EquivalentTo(users.Select(u => u.Id))); + } [Test] - public void METHOD() + public async Task GetNotifiedUsers_ThrowsInvalidOperationException_WhenUserDoesNotExist() { - + // Arrange + _mockFriendshipService.Setup(f => f.GetFriendsAsync(_userId)) + .ThrowsAsync(new NotFoundByKeyException(_userId, "Database is empty.")); + + // Act & Assert + var ex = Assert.ThrowsAsync(async () => await _service.GetNotifiedUsers(_userId)); + Assert.That(ex.Message, Is.EqualTo("User not found")); } -} \ No newline at end of file +} From d5299f2b7923109b075339d515b73839b51332cf Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Wed, 23 Jul 2025 22:46:16 +0700 Subject: [PATCH 065/101] Create PresenceHubTests.cs --- Govor.API.Tests/Hubs/PresenceHubTests.cs | 26 ++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 Govor.API.Tests/Hubs/PresenceHubTests.cs diff --git a/Govor.API.Tests/Hubs/PresenceHubTests.cs b/Govor.API.Tests/Hubs/PresenceHubTests.cs new file mode 100644 index 0000000..2a39aae --- /dev/null +++ b/Govor.API.Tests/Hubs/PresenceHubTests.cs @@ -0,0 +1,26 @@ +using AutoFixture; +using Govor.API.Common.SignalR.Helpers; +using Govor.API.Hubs; +using Govor.Application.Interfaces.UserOnlineStatus; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Govor.API.Tests.Hubs; + +[TestFixture] +[TestOf(typeof(PresenceHub))] +public class PresenceHubTests +{ + + private Mock> _mockLogger; + private Mock _mockUserNotification; + private Mock _mockOnlineUserStore; + private Mock _mockHubUserAccessor; + private Fixture _fixture; + + [SetUp] + public void SetUp() + { + + } +} \ No newline at end of file From caefcd582727a1ad3ac303965e927dfb53b65e5b Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Thu, 24 Jul 2025 13:43:51 +0700 Subject: [PATCH 066/101] test --- Govor.API.Tests/Hubs/PresenceHubTests.cs | 80 ++++++++++++++++++++++++ Govor.API/Hubs/FriendsHub.cs | 1 - Govor.API/Hubs/PresenceHub.cs | 49 +++++++++++---- 3 files changed, 116 insertions(+), 14 deletions(-) diff --git a/Govor.API.Tests/Hubs/PresenceHubTests.cs b/Govor.API.Tests/Hubs/PresenceHubTests.cs index 2a39aae..3a4df98 100644 --- a/Govor.API.Tests/Hubs/PresenceHubTests.cs +++ b/Govor.API.Tests/Hubs/PresenceHubTests.cs @@ -2,6 +2,8 @@ using AutoFixture; using Govor.API.Common.SignalR.Helpers; using Govor.API.Hubs; using Govor.Application.Interfaces.UserOnlineStatus; +using Govor.Core.Repositories.Users; +using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; using Moq; @@ -16,11 +18,89 @@ public class PresenceHubTests private Mock _mockUserNotification; private Mock _mockOnlineUserStore; private Mock _mockHubUserAccessor; + private Mock _mockUserRepository; + private Mock _clientsMock = null!; + private Mock _clientProxyMock = null!; + private Mock _mockContext = null!; + private Mock _groupManagerMock = null!; private Fixture _fixture; + private PresenceHub _hub; + private Guid _userId; [SetUp] public void SetUp() { + _fixture = new Fixture(); + _fixture.Behaviors.OfType().ToList().ForEach(b => _fixture.Behaviors.Remove(b)); + _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); + _mockLogger = new Mock>(); + _mockUserNotification = new Mock(); + _mockOnlineUserStore = new Mock(); + _mockHubUserAccessor = new Mock(); + _mockUserRepository = new Mock(); + + _clientsMock = new Mock(); + _clientProxyMock = new Mock(); + + _userId = Guid.NewGuid(); + _mockHubUserAccessor.Setup(h => h.GetUserId( + It.IsAny(), + It.IsAny())) + .Returns(_userId); + + + _clientsMock.Setup(c => c.Group(It.IsAny())).Returns(_clientProxyMock.Object); + _mockContext = new Mock(); + _mockContext.Setup(c => c.ConnectionId).Returns("test-connection"); + + _groupManagerMock = new Mock(); + + _groupManagerMock.Setup(g => g.AddToGroupAsync(It.IsAny(), It.IsAny(), default)) + .Returns(Task.CompletedTask); + + _hub = new PresenceHub( + _mockLogger.Object, + _mockUserNotification.Object, + _mockOnlineUserStore.Object, + _mockHubUserAccessor.Object, + _mockUserRepository.Object) + { + Clients = _clientsMock.Object, + Context = _mockContext.Object, + Groups = _groupManagerMock.Object, + }; } + + // Tests for OnConnectedAsync action + [Test] + public async Task OnConnectedAsync_SetsUserOnlineAndNotifiesFriends() + { + // Arrange + _mockUserRepository.Setup(f => f.ExistsByIdAsync(_userId)) + .ReturnsAsync(true); + + var friendsIds = _fixture.CreateMany().ToList(); + + _mockUserNotification.Setup(n => n.GetNotifiedUsers(_userId)) + .ReturnsAsync(friendsIds); + + // Act + await _hub.OnConnectedAsync(); + + // Assert + _mockOnlineUserStore.Verify(store => store.SetOnlineUser(_userId), Times.Once); + + _clientsMock.Verify(c => c.Group(It.IsAny()), Times.Exactly(friendsIds.Count)); + + foreach (var friendId in friendsIds) + { + _clientProxyMock.Verify(proxy => + proxy.SendCoreAsync("UserOnline", + It.Is(args => args.Length == 1 && (Guid)args[0] == _userId), + It.IsAny()), + Times.Exactly(friendsIds.Count)); + } + } + } \ No newline at end of file diff --git a/Govor.API/Hubs/FriendsHub.cs b/Govor.API/Hubs/FriendsHub.cs index 50b8e2a..75cf871 100644 --- a/Govor.API/Hubs/FriendsHub.cs +++ b/Govor.API/Hubs/FriendsHub.cs @@ -49,7 +49,6 @@ public class FriendsHub : Hub var userId = _userAccessor.GetUserId(Context, true); if (userId != Guid.Empty) { - // Remove user from their own group await Groups.RemoveFromGroupAsync(Context.ConnectionId, userId.ToString()); _logger.LogInformation( "User {UserId} disconnected with ConnectionId {ConnectionId} and removed from their group", userId, diff --git a/Govor.API/Hubs/PresenceHub.cs b/Govor.API/Hubs/PresenceHub.cs index 76843bc..990bf23 100644 --- a/Govor.API/Hubs/PresenceHub.cs +++ b/Govor.API/Hubs/PresenceHub.cs @@ -1,5 +1,6 @@ using Govor.API.Common.SignalR.Helpers; using Govor.Application.Interfaces.UserOnlineStatus; +using Govor.Core.Repositories.Users; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.SignalR; @@ -11,18 +12,21 @@ namespace Govor.API.Hubs; public class PresenceHub : Hub { private readonly ILogger _logger; - private readonly IUserNotificationScopeService _notificationScopeService; + private readonly IUserNotificationScopeService _scopeService; private readonly IOnlineUserStore _onlineUserStore; private readonly IHubUserAccessor _userAccessor; - + private readonly IUsersRepository _users; + public PresenceHub( ILogger logger, - IUserNotificationScopeService notificationScopeService, + IUserNotificationScopeService scopeService, IOnlineUserStore onlineUserStore, - IHubUserAccessor userAccessor) + IHubUserAccessor userAccessor, + IUsersRepository users) { _logger = logger; - _notificationScopeService = notificationScopeService; + _users = users; + _scopeService = scopeService; _onlineUserStore = onlineUserStore; _userAccessor = userAccessor; } @@ -30,9 +34,9 @@ public class PresenceHub : Hub public override async Task OnConnectedAsync() { var userId = _userAccessor.GetUserId(Context); - if (userId == Guid.Empty) + if (userId == Guid.Empty || await _users.ExistsByIdAsync(userId) == false) { - _logger.LogWarning("User connected with invalid UserID claim."); + _logger.LogWarning("User connected with invalid UserId claim."); Context.Abort(); return; } @@ -40,7 +44,7 @@ public class PresenceHub : Hub _onlineUserStore.SetOnlineUser(userId); await Groups.AddToGroupAsync(Context.ConnectionId, userId.ToString()); - var friends = await _notificationScopeService.GetNotifiedUsers(userId); + var friends = await _scopeService.GetNotifiedUsers(userId); foreach (var recipient in friends) { @@ -51,14 +55,33 @@ public class PresenceHub : Hub await base.OnConnectedAsync(); } - public override async Task OnDisconnectedAsync(Exception? exception) + public override async Task OnDisconnectedAsync(Exception exception) { var userId = _userAccessor.GetUserId(Context, true); - if (userId == Guid.Empty) return; - + + if (userId != Guid.Empty) + { + await Groups.RemoveFromGroupAsync(Context.ConnectionId, userId.ToString()); + _logger.LogInformation( + "User {UserId} disconnected with ConnectionId {ConnectionId} and removed from their group", userId, + Context.ConnectionId); + } + else + { + _logger.LogInformation( + "User disconnected with no exception and invalid UserID claim. ConnectionId: {ConnectionId}", + Context.ConnectionId); + return; + } + _onlineUserStore.SetOfflineUser(userId); - - var friends = await _notificationScopeService.GetNotifiedUsers(userId); + + // Updating was online + var user = await _users.FindByIdAsync(userId); + user.WasOnline = DateTime.UtcNow; + await _users.UpdateAsync(user); + + var friends = await _scopeService.GetNotifiedUsers(userId); foreach (var recipient in friends) { From 73f22a3acebb976f8015fc2426508c92e01bc010 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Thu, 24 Jul 2025 13:54:59 +0700 Subject: [PATCH 067/101] more tests for PresenceHub --- Govor.API.Tests/Hubs/PresenceHubTests.cs | 83 ++++++++++++++++++++++++ Govor.API/Hubs/FriendsHub.cs | 4 +- Govor.API/Hubs/PresenceHub.cs | 2 +- 3 files changed, 86 insertions(+), 3 deletions(-) diff --git a/Govor.API.Tests/Hubs/PresenceHubTests.cs b/Govor.API.Tests/Hubs/PresenceHubTests.cs index 3a4df98..316c00f 100644 --- a/Govor.API.Tests/Hubs/PresenceHubTests.cs +++ b/Govor.API.Tests/Hubs/PresenceHubTests.cs @@ -2,6 +2,7 @@ using AutoFixture; using Govor.API.Common.SignalR.Helpers; using Govor.API.Hubs; using Govor.Application.Interfaces.UserOnlineStatus; +using Govor.Core.Models.Users; using Govor.Core.Repositories.Users; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; @@ -103,4 +104,86 @@ public class PresenceHubTests } } + [Test] + public async Task OnConnectedAsync_UserIdInvalidOrNotExists_AbortsConnection() + { + // Arrange + _mockHubUserAccessor.Setup(h => h.GetUserId(It.IsAny(), false)) + .Returns(Guid.Empty); + + // Act + await _hub.OnConnectedAsync(); + + // Assert + _mockOnlineUserStore.Verify(store => store.SetOnlineUser(It.IsAny()), Times.Never); + _mockContext.Verify(c => c.Abort(), Times.Once); + } + + [Test] + public async Task OnConnectedAsync_UserNotExists_AbortsConnection() + { + // Arrange + _mockUserRepository.Setup(f => f.ExistsByIdAsync(_userId)) + .ReturnsAsync(false); + + // Act + await _hub.OnConnectedAsync(); + + // Assert + _mockOnlineUserStore.Verify(store => store.SetOnlineUser(It.IsAny()), Times.Never); + _mockContext.Verify(c => c.Abort(), Times.Once); + } + + // Tests for OnDisconnectedAsync action + [Test] + public async Task OnDisconnectedAsync_SetsUserOffline_UpdatesWasOnline_NotifiesFriends() + { + // Arrange + var user = new User { Id = _userId, WasOnline = DateTime.MinValue }; + + var friendsIds = _fixture.CreateMany().ToList(); + _mockUserRepository.Setup(r => r.FindByIdAsync(_userId)) + .ReturnsAsync(user); + + _mockUserNotification.Setup(n => n.GetNotifiedUsers(_userId)) + .ReturnsAsync(friendsIds); + + // Act + await _hub.OnDisconnectedAsync(null!); + + // Assert + _mockOnlineUserStore.Verify(store => store.SetOfflineUser(_userId), Times.Once); + _mockUserRepository.Verify(r => r.UpdateAsync(It.Is(u => u.Id == _userId && u.WasOnline > DateTime.MinValue)), Times.Once); + _clientsMock.Verify(c => c.Group(It.IsAny()), Times.AtLeastOnce); + + foreach (var friendId in friendsIds) + { + _clientProxyMock.Verify(proxy => + proxy.SendCoreAsync("UserOffline", + It.Is(args => args.Length == 1 && (Guid)args[0] == _userId), + It.IsAny()), + Times.Exactly(friendsIds.Count)); + } + } + + [Test] + public async Task OnDisconnectedAsync_UserIdEmpty_LogsAndSkips() + { + // Arrange + _mockHubUserAccessor.Setup(h => h.GetUserId(It.IsAny(), true)) + .Returns(Guid.Empty); + + // Act + await _hub.OnDisconnectedAsync(null!); + + // Assert + _mockOnlineUserStore.Verify(s => s.SetOfflineUser(It.IsAny()), Times.Never); + _mockUserRepository.Verify(r => r.UpdateAsync(It.IsAny()), Times.Never); + + _clientProxyMock.Verify(proxy => + proxy.SendCoreAsync("UserOffline", + It.Is(args => args.Length == 1 && (Guid)args[0] == _userId), + It.IsAny()), + Times.Never); + } } \ No newline at end of file diff --git a/Govor.API/Hubs/FriendsHub.cs b/Govor.API/Hubs/FriendsHub.cs index 75cf871..94fa19a 100644 --- a/Govor.API/Hubs/FriendsHub.cs +++ b/Govor.API/Hubs/FriendsHub.cs @@ -57,13 +57,13 @@ public class FriendsHub : Hub else if (exception != null) { _logger.LogWarning(exception, - "User disconnected with an exception and invalid UserID claim. ConnectionId: {ConnectionId}", + "User disconnected with an exception and invalid UserId claim. ConnectionId: {ConnectionId}", Context.ConnectionId); } else { _logger.LogInformation( - "User disconnected with no exception and invalid UserID claim. ConnectionId: {ConnectionId}", + "User disconnected with no exception and invalid UserId claim. ConnectionId: {ConnectionId}", Context.ConnectionId); } diff --git a/Govor.API/Hubs/PresenceHub.cs b/Govor.API/Hubs/PresenceHub.cs index 990bf23..c06de5c 100644 --- a/Govor.API/Hubs/PresenceHub.cs +++ b/Govor.API/Hubs/PresenceHub.cs @@ -69,7 +69,7 @@ public class PresenceHub : Hub else { _logger.LogInformation( - "User disconnected with no exception and invalid UserID claim. ConnectionId: {ConnectionId}", + "User disconnected with no exception and invalid UserId claim. ConnectionId: {ConnectionId}", Context.ConnectionId); return; } From 53ea25f9fdbc2dbbde4792bec47eaaae2b00309a Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Thu, 24 Jul 2025 14:45:01 +0700 Subject: [PATCH 068/101] small fix --- .../Controllers/FriendshipControllerTests.cs | 16 +--------------- .../Friends/FriendsRequestQueryController.cs | 12 +++++++++++- .../Controllers/Friends/FriendshipController.cs | 9 +++++++-- .../Services/Friends/FriendshipServiceTests.cs | 9 ++++++--- .../Services/Friends/FriendshipService.cs | 3 +-- 5 files changed, 26 insertions(+), 23 deletions(-) diff --git a/Govor.API.Tests/IntegrationTests/Controllers/FriendshipControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/FriendshipControllerTests.cs index 85bda4b..6d712fb 100644 --- a/Govor.API.Tests/IntegrationTests/Controllers/FriendshipControllerTests.cs +++ b/Govor.API.Tests/IntegrationTests/Controllers/FriendshipControllerTests.cs @@ -82,21 +82,7 @@ public class FriendshipControllerTests var badRequestResult = result as BadRequestObjectResult; Assert.That(badRequestResult.Value, Is.EqualTo("Query cannot be empty")); } - - - [Test] - public async Task Search_NotFound_IfThrowsSearchUsersException() - { - // Arrange - _friendsServiceMock.Setup(f => f.SearchUsersAsync(It.IsAny(), It.IsAny())) - .ThrowsAsync(new SearchUsersException(_fixture.Create())); - - // Act - var result = await _controller.Search(_fixture.Create()); - // Assert - Assert.That(result, Is.InstanceOf()); - } - + [Test] public async Task Search_StatusCode500_IfThrowsSomeException() { diff --git a/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs b/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs index 251765e..9609238 100644 --- a/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs +++ b/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs @@ -36,7 +36,7 @@ public class FriendsRequestQueryController : Controller { var result = await _friendsService.GetIncomingAsync(_currentUserService.GetCurrentUserId()); var response = _mapper.Map>(result); - + return Ok(response); } catch (InvalidOperationException ex) @@ -44,6 +44,11 @@ public class FriendsRequestQueryController : Controller _logger.LogWarning(ex, ex.Message); return Ok(new List()); } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, ex.Message); + return Forbid(ex.Message); + } catch (Exception ex) { _logger.LogError(ex, ex.Message); @@ -70,6 +75,11 @@ public class FriendsRequestQueryController : Controller _logger.LogWarning(ex, ex.Message); return Ok(new List()); } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, ex.Message); + return Forbid(ex.Message); + } catch (Exception ex) { _logger.LogError(ex, ex.Message); diff --git a/Govor.API/Controllers/Friends/FriendshipController.cs b/Govor.API/Controllers/Friends/FriendshipController.cs index fa6cd10..61bb81a 100644 --- a/Govor.API/Controllers/Friends/FriendshipController.cs +++ b/Govor.API/Controllers/Friends/FriendshipController.cs @@ -41,10 +41,10 @@ public class FriendshipController : Controller return Ok(response); } - catch (SearchUsersException ex) + catch (UnauthorizedAccessException ex) { _logger.LogWarning(ex, ex.Message); - return NotFound(new { error = ex.Message }); + return Forbid(ex.Message); } catch (Exception ex) { @@ -69,6 +69,11 @@ public class FriendshipController : Controller _logger.LogError(ex, ex.Message); return Ok(Array.Empty()); } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, ex.Message); + return Forbid(ex.Message); + } catch (Exception ex) { _logger.LogError(ex, ex.Message); diff --git a/Govor.Application.Tests/Services/Friends/FriendshipServiceTests.cs b/Govor.Application.Tests/Services/Friends/FriendshipServiceTests.cs index df8d52d..672dc86 100644 --- a/Govor.Application.Tests/Services/Friends/FriendshipServiceTests.cs +++ b/Govor.Application.Tests/Services/Friends/FriendshipServiceTests.cs @@ -61,15 +61,18 @@ public class FriendshipServiceTests } [Test] - public void SearchUsersAsync_Throws_SearchUsersException_IfThrowsNotFoundByKeyException_String_Guid() + public async Task SearchUsersAsync_Returns_EmptyList_IfThrowsNotFoundByKeyException_String_Guid() { // Arrange _usersRepositoryMock .Setup(u => u.SearchPotentialFriendsAsync(It.IsAny(), It.IsAny())) .ThrowsAsync(new NotFoundByKeyException<(string,Guid)>(("test",Guid.NewGuid()),"test")); - // Axt & Assert - Assert.ThrowsAsync(async () => await _service.SearchUsersAsync("test", Guid.NewGuid())); + // Act + var users = await _service.SearchUsersAsync("test", Guid.NewGuid()); + + // & Assert + Assert.That(users, Is.Empty); } [Test] diff --git a/Govor.Application/Services/Friends/FriendshipService.cs b/Govor.Application/Services/Friends/FriendshipService.cs index a6bfa9f..20e3a4a 100644 --- a/Govor.Application/Services/Friends/FriendshipService.cs +++ b/Govor.Application/Services/Friends/FriendshipService.cs @@ -33,8 +33,7 @@ public class FriendshipService : IFriendshipService } catch (NotFoundByKeyException<(string, Guid)> ex) { - throw new SearchUsersException( - $"Users with given query: \"{query}\" for user with id {currentId} was not found", ex); + return []; } catch (NotFoundByKeyException ex) { From 19f1ccdfd510e4fa0adbd87ce8e9d763537ef3b7 Mon Sep 17 00:00:00 2001 From: stalcke2288969 Date: Thu, 24 Jul 2025 08:07:52 +0000 Subject: [PATCH 069/101] GitBook: No commit message --- README.md | 2 + SUMMARY.md | 28 ++ .../hubresult-less-than-t-greater-than.md | 164 ++++++++ endpoints/authentication/README.md | 45 ++ endpoints/authentication/authcontroller.md | 141 +++++++ endpoints/authentication/refreshcontroller.md | 90 ++++ endpoints/chatloadcontroller.md | 259 ++++++++++++ endpoints/friendshipcontroller.md | 114 ++++++ endpoints/friendsrequestquerycontroller.md | 109 +++++ endpoints/mediacontroller.md | 107 +++++ .../onlinepingingcontroller-ne-rabotaet.md | 50 +++ friendshub/README.md | 235 +++++++++++ friendshub/friendshub-client-java.md | 384 ++++++++++++++++++ signalr/chathub.md | 162 ++++++++ signalr/presencehub.md | 53 +++ 15 files changed, 1943 insertions(+) create mode 100644 README.md create mode 100644 SUMMARY.md create mode 100644 code-docs/hubresult-less-than-t-greater-than.md create mode 100644 endpoints/authentication/README.md create mode 100644 endpoints/authentication/authcontroller.md create mode 100644 endpoints/authentication/refreshcontroller.md create mode 100644 endpoints/chatloadcontroller.md create mode 100644 endpoints/friendshipcontroller.md create mode 100644 endpoints/friendsrequestquerycontroller.md create mode 100644 endpoints/mediacontroller.md create mode 100644 endpoints/onlinepingingcontroller-ne-rabotaet.md create mode 100644 friendshub/README.md create mode 100644 friendshub/friendshub-client-java.md create mode 100644 signalr/chathub.md create mode 100644 signalr/presencehub.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..5c4b4d5 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# Page + diff --git a/SUMMARY.md b/SUMMARY.md new file mode 100644 index 0000000..3412688 --- /dev/null +++ b/SUMMARY.md @@ -0,0 +1,28 @@ +# Table of contents + +* [Page](README.md) + +## Endpoints + +* [Authentication](endpoints/authentication/README.md) + * [AuthController](endpoints/authentication/authcontroller.md) + * [RefreshController](endpoints/authentication/refreshcontroller.md) +* [FriendshipController](endpoints/friendshipcontroller.md) +* [FriendsRequestQueryController](endpoints/friendsrequestquerycontroller.md) +* [MediaController](endpoints/mediacontroller.md) +* [ChatLoadController](endpoints/chatloadcontroller.md) +* [OnlinePingingController(не работает)](endpoints/onlinepingingcontroller-ne-rabotaet.md) + +## SignalR + +* [PresenceHub](signalr/presencehub.md) +* [ChatHub](signalr/chathub.md) + +*** + +* [FriendsHub](friendshub/README.md) + * [FriendsHub Client (Java)](friendshub/friendshub-client-java.md) + +## Code Docs + +* [HubResult\](code-docs/hubresult-less-than-t-greater-than.md) diff --git a/code-docs/hubresult-less-than-t-greater-than.md b/code-docs/hubresult-less-than-t-greater-than.md new file mode 100644 index 0000000..8e1bf6d --- /dev/null +++ b/code-docs/hubresult-less-than-t-greater-than.md @@ -0,0 +1,164 @@ +--- +description: >- + Description: A generic class used to encapsulate the result of SignalR hub + operations, providing a standardized way to return status, data, and error + messages. It supports various HTTP-like status cod +--- + +# HubResult\ + +**Namespace**: `Govor.Contracts.Responses.SignalR` + +### Properties + +* **Status**: (`HubResultStatus`) Indicates the status of the hub operation, corresponding to HTTP-like status codes (e.g., Success, BadRequest). +* **Result**: (`T?`) The data returned by the hub operation, if applicable. Can be null for error cases or when no data is returned. +* **ErrorMessage**: (`string?`) A message describing the error, if the operation failed. Null for successful operations. + +### Static Methods + +#### Ok + +**Description**: Creates a successful result with an optional data payload.\ +**Parameters**: + +* `result`: (`T?`, optional) The data to include in the response (default: `null`).\ + **Returns**: A `HubResult` with `Status` set to `Success` (200) and the provided `result`.\ + **Example**: + +```csharp +HubResult result = HubResult.Ok("Operation completed"); +``` + +#### Created + +**Description**: Creates a result indicating a resource was created, with an optional data payload.\ +**Parameters**: + +* `result`: (`T?`, optional) The data to include in the response (default: `null`).\ + **Returns**: A `HubResult` with `Status` set to `Created` (201) and the provided `result`.\ + **Example**: + +```csharp +HubResult result = HubResult.Created("Resource created"); +``` + +#### NoContent + +**Description**: Creates a result indicating the operation was successful but no data is returned.\ +**Parameters**: None\ +**Returns**: A `HubResult` with `Status` set to `NoContent` (204) and `Result` set to `null`.\ +**Example**: + +```csharp +HubResult result = HubResult.NoContent(); +``` + +#### BadRequest + +**Description**: Creates a result indicating a client error due to invalid input.\ +**Parameters**: + +* `message`: (`string`) The error message describing the issue. +* `details`: (`T?`, optional) Additional details about the error (default: `null`).\ + **Returns**: A `HubResult` with `Status` set to `BadRequest` (400), the provided `message`, and optional `details`.\ + **Example**: + +```csharp +HubResult result = HubResult.BadRequest("Invalid input provided"); +``` + +#### NotFound + +**Description**: Creates a result indicating a requested resource was not found.\ +**Parameters**: + +* `message`: (`string`) The error message describing the issue. +* `details`: (`T?`, optional) Additional details about the error (default: `null`).\ + **Returns**: A `HubResult` with `Status` set to `NotFound` (404), the provided `message`, and optional `details`.\ + **Example**: + +```csharp +HubResult result = HubResult.NotFound("User not found"); +``` + +#### Unauthorized + +**Description**: Creates a result indicating the client is not authorized to perform the operation.\ +**Parameters**: + +* `message`: (`string`) The error message describing the issue.\ + **Returns**: A `HubResult` with `Status` set to `Unauthorized` (401) and the provided `message`.\ + **Example**: + +```csharp +HubResult result = HubResult.Unauthorized("Authentication required"); +``` + +#### Conflict + +**Description**: Creates a result indicating a conflict, such as a duplicate resource.\ +**Parameters**: + +* `message`: (`string`) The error message describing the issue.\ + **Returns**: A `HubResult` with `Status` set to `Conflict` (409) and the provided `message`.\ + **Example**: + +```csharp +HubResult result = HubResult.Conflict("Resource already exists"); +``` + +#### UnprocessableEntity + +**Description**: Creates a result indicating the request could not be processed due to semantic errors.\ +**Parameters**: + +* `message`: (`string`) The error message describing the issue.\ + **Returns**: A `HubResult` with `Status` set to `UnprocessableEntity` (422) and the provided `message`.\ + **Example**: + +```csharp +HubResult result = HubResult.UnprocessableEntity("Invalid data format"); +``` + +#### Error + +**Description**: Creates a result indicating an internal server error.\ +**Parameters**: + +* `message`: (`string`) The error message describing the issue.\ + **Returns**: A `HubResult` with `Status` set to `ServerError` (500) and the provided `message`.\ + **Example**: + +```csharp +HubResult result = HubResult.Error("Unexpected server error"); +``` + +### Enum: HubResultStatus + +**Description**: Enumerates the possible status codes for a `HubResult` response, aligning with HTTP status codes for consistency in SignalR hub operations. + +**Values**: + +* **Success** (200): The operation was successful. +* **Created** (201): A resource was successfully created. +* **NoContent** (204): The operation was successful, but no content is returned. +* **BadRequest** (400): The request was invalid or malformed. +* **Unauthorized** (401): The client is not authorized to perform the operation. +* **NotFound** (404): The requested resource was not found. +* **Conflict** (409): The request conflicts with an existing resource. +* **UnprocessableEntity** (422): The request could not be processed due to semantic errors. +* **ServerError** (500): An unexpected server error occurred. + +### Example Usage + +```csharp +// Successful operation with data +var successResult = HubResult.Ok(new UserDto { Id = Guid.NewGuid(), Username = "example" }); + +// Error due to invalid input +var errorResult = HubResult.BadRequest("Invalid username provided"); + +// Not found scenario +var notFoundResult = HubResult.NotFound("User not found in database"); +``` diff --git a/endpoints/authentication/README.md b/endpoints/authentication/README.md new file mode 100644 index 0000000..c891b49 --- /dev/null +++ b/endpoints/authentication/README.md @@ -0,0 +1,45 @@ +--- +description: How to work with jwt tokens +icon: user +--- + +# Authentication + +### Components + +* **AuthController**: Handles registration and login. + * **Route**: `/api/auth` +* **RefreshController**: Manages token refresh. + * **Route**: `/api/auth/token` + +### Authentication Process + +* **Registration**: + * **Endpoint**: `POST /api/auth/register` + * **Input**: `RegistrationRequest` (name, password, inviteLink, deviceInfo) + * **Output**: Access token + * **Action**: Validates invite, registers user, opens session. +* **Login**: + * **Endpoint**: `POST /api/auth/login` + * **Input**: `LoginRequest` (name, password, deviceInfo) + * **Output**: Access token + * **Action**: Authenticates user, opens session. +* **Token Refresh**: + * **Endpoint**: `POST /api/auth/token/refresh` + * **Input**: `RefreshTokenRequest` (refreshToken) + * **Output**: `RefreshTokenResponse` (accessToken, refreshToken) + * **Action**: Refreshes expired access token. + +### Token Usage + +* **Access Token**: + * **Request**: Obtain during registration or login. + * **Refresh**: Request via `/api/auth/token/refresh` when expired (e.g., 02:12 PM CEST, July 21, 2025). +* **Refresh Token**: + * **Request**: Received during registration or login. + * **Refresh**: Use to obtain new access token when current one expires. + +### Security + +* **Protocol**: Requires HTTPS. +* **Storage**: Store refresh token in HTTP-only cookie with `Secure` and `SameSite=Strict`. diff --git a/endpoints/authentication/authcontroller.md b/endpoints/authentication/authcontroller.md new file mode 100644 index 0000000..3cd6d03 --- /dev/null +++ b/endpoints/authentication/authcontroller.md @@ -0,0 +1,141 @@ +--- +description: >- + Controller for handling user authentication operations, including registration + and login, with session management and invite-based registration. +--- + +# AuthController + +### Controller Description + +* **Route**: `api/auth` +* **Authorize**: Allows anonymous access (`[AllowAnonymous]`). + +### Endpoints + +#### Register + +* **Description**: Registers a new user using a valid invite link and opens a user session, returning an authentication token. +* **Route**: `[POST] api/auth/register` +* **HTTP Method**: `POST` +* **Request**: + * **Content-Type**: `application/json` + * **Request Body**: `RegistrationRequest` object with the following structure: + + ```json + { + "name": "string", + "password": "string", + "inviteLink": "string", + "deviceInfo": "string" + } + ``` +* **Responses**: + * **200 OK**: Returns the authentication token upon successful registration and session creation. + + ```json + { + "refreshToken": "string", + "accessToken": "string" + } + ``` + * **400 Bad Request**: + * If model validation fails. + * If the user already exists: `"Registration failed: user already exists."` + * If the invite link is invalid: `"Invite link invalid."` + * If the username is invalid: `"Invalid username: "` + * **500 Internal Server Error**: Indicates an unexpected error. + + ```json + "An unexpected error occurred. Please try again later." + ``` + +#### Login + +* **Description**: Authenticates an existing user and opens a session, returning an authentication token. +* **Route**: `[POST] api/auth/login` +* **HTTP Method**: `POST` +* **Request**: + * **Content-Type**: `application/json` + * **Request Body**: `LoginRequest` object with the following structure: + + ```json + { + "name": "string", + "password": "string", + "deviceInfo": "string" + } + ``` +* **Responses**: + * **200 OK**: Returns the authentication token upon successful login and session creation. + + ```json + { + "refreshToken": "string", + "accessToken": "string" + } + ``` + * **400 Bad Request**: + * If model validation fails. + * If the user does not exist: `"Login failed: user does not exist."` + * If the username or password is incorrect: `"Login failed: username or password is incorrect."` + * **500 Internal Server Error**: Indicates an unexpected error. + + ```json + "An unexpected error occurred. Please try again later." + ``` + +### Data Models + +#### RegistrationRequest + +* **Description**: Model for registration request data. +* **Structure**: + + ```json + { + "name": "string", + "password": "string", + "inviteLink": "string", + "deviceInfo": "string" + } + ``` + +#### LoginRequest + +* **Description**: Model for login request data. +* **Structure**: + + ```json + { + "name": "string", + "password": "string", + "deviceInfo": "string" + } + ``` + +#### RefreshResult + +* **Description**: Model for refresh token response data. +* **Structure**: + + ```json + { + "refreshToken": "string", + "accessToken": "string" + } + ``` + +### Error Handling + +* **Invalid User ID**: Not applicable (handled by session service). +* **Invalid Operations**: Registration fails if the user already exists, invite link is invalid, or username is invalid, returning `400 Bad Request`. +* **Unauthorized Access**: Not applicable (endpoint is `[AllowAnonymous]`). +* **Unexpected Errors**: General exceptions are caught, logged, and returned as `500 Internal Server Error`. + +### Logging + +* Logs successful registration and login events with username and user ID. +* Logs session opening events with username and user ID. +* Logs warnings for specific exceptions (e.g., user already exists, invalid invite link, login failures). +* Logs errors for unexpected exceptions during registration and login. diff --git a/endpoints/authentication/refreshcontroller.md b/endpoints/authentication/refreshcontroller.md new file mode 100644 index 0000000..403b6b5 --- /dev/null +++ b/endpoints/authentication/refreshcontroller.md @@ -0,0 +1,90 @@ +--- +description: >- + Controller for managing token refresh operations, allowing users to refresh + their access tokens using a valid refresh token. +--- + +# RefreshController + +### Controller Description + +* **Route**: `api/auth/token` +* **Authorize**: Allows anonymous access (`[AllowAnonymous]`). + +### Endpoints + +#### Refresh + +* **Description**: Refreshes an access token using a provided refresh token and returns a new access token and refresh token pair. +* **Route**: `[POST] api/auth/token/refresh` +* **HTTP Method**: `POST` +* **Request**: + * **Content-Type**: `application/json` + * **Request Body**: `RefreshTokenRequest` object with the following structure: + + ```json + { + "refreshToken": "string" + } + ``` +* **Responses**: + * **200 OK**: Returns a new access token and refresh token upon successful refresh. + + ```json + { + "accessToken": "string", + "refreshToken": "string" + } + ``` + * **400 Bad Request**: If model validation fails or the refresh token is empty. + + ```json + "Refresh token cant be empty." + ``` + * **401 Unauthorized**: If the refresh token is invalid or authorization fails. + + ```json + "Invalid refresh token" + ``` + * **500 Internal Server Error**: Indicates an unexpected error. + + ```json + "An unexpected error occurred." + ``` + +### Data Models + +#### RefreshTokenRequest + +* **Description**: Model for refresh token request data. +* **Structure**: + + ```json + { + "refreshToken": "string" + } + ``` + +#### RefreshTokenResponse + +* **Description**: Model for refresh token response data. +* **Structure**: + + ```json + { + "accessToken": "string", + "refreshToken": "string" + } + ``` + +### Error Handling + +* **Invalid User ID**: Not applicable (handled by session service). +* **Invalid Operations**: Returns `400 Bad Request` if the refresh token is empty or invalid. +* **Unauthorized Access**: Returns `401 Unauthorized` if the refresh token fails authorization. +* **Unexpected Errors**: General exceptions are caught, logged, and returned as `500 Internal Server Error`. + +### Logging + +* Logs warnings for invalid or failed refresh token attempts. +* Logs errors for unexpected exceptions during token refresh. diff --git a/endpoints/chatloadcontroller.md b/endpoints/chatloadcontroller.md new file mode 100644 index 0000000..5917f14 --- /dev/null +++ b/endpoints/chatloadcontroller.md @@ -0,0 +1,259 @@ +--- +description: Controller for loading chat messages for users and groups with pagination. +--- + +# ChatLoadController + +### Controller Description + +* **Route**: `api/chats` +* **Authorize**: Requires authenticated user with roles "Admin" or "User" (`[Authorize(Roles = "Admin, User")]`). + +### Endpoints + +#### GetGroupMessages + +* **Description**: Retrieves messages from a specified group chat with pagination. +* **Route**: `[GET] api/chats/group-messages` +* **HTTP Method**: `GET` +* **Request**: + * **Path Parameter**: `chatId` (Guid, required): ID of the group chat. + * **Query Parameter**: `query` (`MessageQuery`, required) with: + * `startMessageId` (Guid?, optional): ID of the starting message. + * `before` (int, default=20): Number of messages before the start. + * `after` (int, default=2): Number of messages after the start. +* **Responses**: + * **200 OK**: Returns a list of group chat messages. + + ```json + [ + { + "id": "Guid", + "senderId": "Guid", + "recipientId": "Guid", + "recipientType": "string", + "encryptedContent": "string", + "sentAt": "DateTime", + "isEdited": "boolean", + "editedAt": "DateTime?", + "replyToMessageId": "Guid?", + "mediaAttachments": [ + { + "id": "Guid", + "messageId": "Guid", + "mediaFileId": "Guid" + } + ], + "reactions": [ + { + "id": "Guid", + "messageId": "Guid", + "userId": "Guid", + "reactionCode": "string", + "reactedAt": "DateTime" + } + ], + "messageViews": [ + { + "id": "Guid", + "messageId": "Guid", + "userId": "Guid", + "viewedAt": "DateTime" + } + ] + } + ] + ``` + * **400 Bad Request**: + * If `before` or `after` is negative or total exceeds 100: `"Values must be non-negative and total must not exceed 100."` + * If an argument or resource is invalid: `"string"` + * **403 Forbidden**: If user lacks authorization. + + ```json + "string" + ``` + * **500 Internal Server Error**: Indicates an unexpected error. + + ```json + "Unexpected Error! Please try again later." + ``` + +#### GetUserMessages + +* **Description**: Retrieves messages from a specified user chat with pagination. +* **Route**: `[GET] api/chats/user-messages` +* **HTTP Method**: `GET` +* **Request**: + * **Path Parameter**: `userId` (Guid, required): ID of the target user. + * **Query Parameter**: `query` (`MessageQuery`, required) with: + * `startMessageId` (Guid?, optional): ID of the starting message. + * `before` (int, default=20): Number of messages before the start. + * `after` (int, default=2): Number of messages after the start. +* **Responses**: + * **200 OK**: Returns a list of user chat messages. + + ```json + [ + { + "id": "Guid", + "senderId": "Guid", + "recipientId": "Guid", + "recipientType": "string", + "encryptedContent": "string", + "sentAt": "DateTime", + "isEdited": "boolean", + "editedAt": "DateTime?", + "replyToMessageId": "Guid?", + "mediaAttachments": [ + { + "id": "Guid", + "messageId": "Guid", + "mediaFileId": "Guid" + } + ], + "reactions": [ + { + "id": "Guid", + "messageId": "Guid", + "userId": "Guid", + "reactionCode": "string", + "reactedAt": "DateTime" + } + ], + "messageViews": [ + { + "id": "Guid", + "messageId": "Guid", + "userId": "Guid", + "viewedAt": "DateTime" + } + ] + } + ] + ``` + * **400 Bad Request**: + * If `before` or `after` is negative or total exceeds 100: `"Values must be non-negative and total must not exceed 100."` + * If an operation or resource is invalid: `"string"` + * **403 Forbidden**: If user lacks authorization. + + ```json + "string" + ``` + * **500 Internal Server Error**: Indicates an unexpected error. + + ```json + "Unexpected Error! Please try again later." + ``` + +### Data Models + +#### MessageQuery + +* **Description**: Model for pagination query parameters. +* **Structure**: + + ```json + { + "startMessageId": "Guid?", + "before": "int", + "after": "int" + } + ``` + +#### MessageResponse + +* **Description**: Data transfer object for chat message details. +* **Structure**: + + ```json + { + "id": "Guid", + "senderId": "Guid", + "recipientId": "Guid", + "recipientType": "string", + "encryptedContent": "string", + "sentAt": "DateTime", + "isEdited": "boolean", + "editedAt": "DateTime?", + "replyToMessageId": "Guid?", + "mediaAttachments": [ + { + "id": "Guid", + "messageId": "Guid", + "mediaFileId": "Guid" + } + ], + "reactions": [ + { + "id": "Guid", + "messageId": "Guid", + "userId": "Guid", + "reactionCode": "string", + "reactedAt": "DateTime" + } + ], + "messageViews": [ + { + "id": "Guid", + "messageId": "Guid", + "userId": "Guid", + "viewedAt": "DateTime" + } + ] + } + ``` + +#### MediaAttachmentResponse + +* **Description**: Data transfer object for media attachments in messages. +* **Structure**: + + ```json + { + "id": "Guid", + "messageId": "Guid", + "mediaFileId": "Guid" + } + ``` + +#### MessageReactionResponse + +* **Description**: Data transfer object for message reactions. +* **Structure**: + + ```json + { + "id": "Guid", + "messageId": "Guid", + "userId": "Guid", + "reactionCode": "string", + "reactedAt": "DateTime" + } + ``` + +#### MessageViewResponse + +* **Description**: Data transfer object for message view tracking. +* **Structure**: + + ```json + { + "id": "Guid", + "messageId": "Guid", + "userId": "Guid", + "viewedAt": "DateTime" + } + ``` + +### Error Handling + +* **Invalid User ID**: Handled by `ICurrentUserService`, aborts if invalid. +* **Invalid Operations**: Returns `400 Bad Request` for negative values, total exceeding 100, or `InvalidOperationException`. +* **Unauthorized Access**: Returns `403 Forbidden` for `UnauthorizedAccessException`. +* **Resource Not Found**: Returns `400 Bad Request` for `NotFoundException`. +* **Unexpected Errors**: Caught and returned as `500 Internal Server Error`. + +### Logging + +* Logs warnings for `UnauthorizedAccessException`, `NotFoundException`, `ArgumentException`, and `InvalidOperationException`. +* Logs errors for unexpected exceptions. diff --git a/endpoints/friendshipcontroller.md b/endpoints/friendshipcontroller.md new file mode 100644 index 0000000..4ba6894 --- /dev/null +++ b/endpoints/friendshipcontroller.md @@ -0,0 +1,114 @@ +--- +description: >- + Description: Controller for managing friendship-related operations, including + searching for users and retrieving the list of friends for the current user. +--- + +# FriendshipController + +### Controller Description + +* **Route**: `api/friends` +* **Authorize**: Requires authenticated user (`[Authorize]`). + +### Endpoints + +#### Search + +* **Description**: Searches for users based on a query string. +* **Route**: `[GET] api/friends/search?query=` +* **HTTP Method**: `GET` +* **Request**: + * **Query Parameter**: `query` (string, required) +* **Responses**: + * **200 OK**: Returns a list of matching users. + + ```json + [ + { + "id": "Guid", + "username": "string", + "description": "string", + "wasOnline": "DateTime", + "iconId": "Guid" + } + ] + ``` + * **400 Bad Request**: If the query is empty. + + ```json + "Query cannot be empty" + ``` + * **403 Forbidden**: If user lacks authorization. + + ```json + "string" + ``` + * **500 Internal Server Error**: Indicates an unexpected error. + + ```json + { + "error": "Internal error during user search." + } + ``` + +#### GetFriends + +* **Description**: Retrieves the list of friends for the authenticated user. +* **Route**: `[GET] api/friends` +* **HTTP Method**: `GET` +* **Request**: None +* **Responses**: + * **200 OK**: Returns a list of the user's friends or an empty list if none. + + ```json + [ + { + "id": "Guid", + "username": "string", + "description": "string", + "wasOnline": "DateTime", + "iconId": "Guid" + } + ] + ``` + * **403 Forbidden**: If user lacks authorization. + + ```json + "string" + ``` + * **500 Internal Server Error**: Indicates an unexpected error. + + ```json + { + "error": "Internal server error." + } + ``` + +### Data Models + +#### UserDto + +* **Description**: Data transfer object for user information. +* **Structure**: + + ```json + { + "id": "Guid", + "username": "string", + "description": "string", + "wasOnline": "DateTime", + "iconId": "Guid" + } + ``` + +### Error Handling + +* **Invalid User ID**: Handled by `ICurrentUserService`, aborts if invalid. +* **Invalid Operations**: Returns `400 Bad Request` for empty query. +* **Unexpected Errors**: Caught and returned as `500 Internal Server Error`. + +### Logging + +* Logs warnings for `SearchUsersException`. +* Logs errors for unexpected exceptions and `InvalidOperationException`. diff --git a/endpoints/friendsrequestquerycontroller.md b/endpoints/friendsrequestquerycontroller.md new file mode 100644 index 0000000..0af2473 --- /dev/null +++ b/endpoints/friendsrequestquerycontroller.md @@ -0,0 +1,109 @@ +--- +description: >- + Description: Controller for querying friend request-related data, including + incoming friend requests and responses to sent friend requests. +--- + +# FriendsRequestQueryController + +### Controller Description + +* **Route**: `api/friends` +* **Authorize**: Requires authenticated user (`[Authorize]`). + +### Endpoints + +#### GetIncomingRequests + +* **Description**: Retrieves a list of incoming friend requests for the authenticated user. +* **Route**: `[GET] api/friends/requests` +* **HTTP Method**: `GET` +* **Request**: None +* **Responses**: + * **200 OK**: Returns a list of incoming friend requests or an empty list if none. + + ```json + [ + { + "id": "Guid", + "senderId": "Guid", + "receiverId": "Guid", + "status": "string", + "createdAt": "DateTime" + } + ] + ``` + * **403 Forbidden**: If user lacks authorization. + + ```json + "string" + ``` + * **500 Internal Server Error**: Indicates an unexpected error. + + ```json + { + "error": "Internal server error." + } + ``` + +#### GetResponses + +* **Description**: Retrieves a list of responses to the authenticated user's sent friend requests. +* **Route**: `[GET] api/friends/responses` +* **HTTP Method**: `GET` +* **Request**: None +* **Responses**: + * **200 OK**: Returns a list of friend request responses or an empty list if none. + + ```json + [ + { + "id": "Guid", + "senderId": "Guid", + "receiverId": "Guid", + "status": "string", + "createdAt": "DateTime" + } + ] + ``` + * **403 Forbidden**: If user lacks authorization. + + ```json + "string" + ``` + * **500 Internal Server Error**: Indicates an unexpected error. + + ```json + { + "error": "Internal server error." + } + ``` + +### Data Models + +#### FriendshipDto + +* **Description**: Data transfer object for friend request information. +* **Structure**: + + ```json + { + "id": "Guid", + "senderId": "Guid", + "receiverId": "Guid", + "status": "string", + "createdAt": "DateTime" + } + ``` + +### Error Handling + +* **Invalid User ID**: Handled by `ICurrentUserService`, aborts if invalid. +* **Invalid Operations**: Returns `200 OK` with an empty list for `InvalidOperationException`. +* **Unexpected Errors**: Caught and returned as `500 Internal Server Error`. + +### Logging + +* Logs warnings for `InvalidOperationException`. +* Logs information for successful response retrieval. +* Logs errors for unexpected exceptions. diff --git a/endpoints/mediacontroller.md b/endpoints/mediacontroller.md new file mode 100644 index 0000000..a9b47f5 --- /dev/null +++ b/endpoints/mediacontroller.md @@ -0,0 +1,107 @@ +--- +description: Controller for uploading and downloading media files. +--- + +# MediaController + +### Controller Description + +* **Route**: `api/media` +* **Authorize**: Requires authenticated user with roles "User" or "Admin" (`[Authorize(Roles = "User,Admin")]`). + +### Endpoints + +#### Upload + +* **Description**: Uploads a media file with associated metadata. +* **Route**: `[POST] api/media/upload` +* **HTTP Method**: `POST` +* **Request**: + * **Content-Type**: `multipart/form-data` + * **Request Body**: `MediaUploadRequest` object with the following structure: + + ```json + { + "fromFile": "IFormFile", + "type": "MediaType", + "mimeType": "string", + "encryptedKey": "string" + } + ``` + * **Constraints**: File size limit of 20 MB. +* **Responses**: + * **200 OK**: Returns the upload result. + + ```json + "string" // Media ID or similar result + ``` + * **400 Bad Request**: + * If model validation fails. + * If no file is uploaded: `"No file uploaded"` + * If file exceeds 20 MB: `"File is too large"` + * If MIME type is missing: `"Missing MIME type"` + * If operation is invalid: `"string"` + * **403 Forbidden**: If user lacks authorization. + + ```json + "string" + ``` + * **500 Internal Server Error**: Indicates an unexpected error. + + ```json + "Internal server error" + ``` + +#### Download + +* **Description**: Downloads a media file by its ID. +* **Route**: `[GET] api/media/download/{id}` +* **HTTP Method**: `GET` +* **Request**: + * **Path Parameter**: `id` (Guid, required): ID of the media file. +* **Responses**: + * **200 OK**: Returns the media file as a stream. + * **Content-Type**: Matches `MimeType` of the media. + * **Content-Disposition**: Includes `filename` from `FileName`. + * **403 Forbidden**: If user lacks access. + * No body. + * **404 Not Found**: If media is not found. + + ```json + "Media not found" + ``` + * **500 Internal Server Error**: Indicates an unexpected error. + + ```json + "Internal server error" + ``` + +### Data Models + +#### MediaUploadRequest + +* **Description**: Model for media upload request data. +* **Structure**: + + ```json + { + "fromFile": "IFormFile", + "type": "MediaType", + "mimeType": "string", + "encryptedKey": "string" + } + ``` + +### Error Handling + +* **Invalid User ID**: Handled by `ICurrentUserService`, aborts if invalid. +* **Invalid Operations**: Returns `400 Bad Request` for `InvalidOperationException`. +* **Unauthorized Access**: Returns `403 Forbidden` for `UnauthorizedAccessException`. +* **Resource Not Found**: Returns `404 Not Found` for `KeyNotFoundException`. +* **Unexpected Errors**: Caught and returned as `500 Internal Server Error`. + +### Logging + +* Logs warnings for `UnauthorizedAccessException`, `InvalidOperationException`, and `KeyNotFoundException`. +* Logs information for successful file uploads. +* Logs errors for unexpected exceptions during upload and download. diff --git a/endpoints/onlinepingingcontroller-ne-rabotaet.md b/endpoints/onlinepingingcontroller-ne-rabotaet.md new file mode 100644 index 0000000..adb67f5 --- /dev/null +++ b/endpoints/onlinepingingcontroller-ne-rabotaet.md @@ -0,0 +1,50 @@ +--- +description: >- + Description: Controller for handling user online status updates by processing + ping requests. +--- + +# OnlinePingingController(не работает) + +**Route**: `api/online`\ +**Authorize**: Requires "User" or "Admin" role + +### End Point {PATCH} + +`[PATCH] api/online/ping` + +#### Description + +Updates the online status of the current user by sending a ping request. + +**Request** + +* **Content-Type**: `application/json` +* **Request Body**: None + +**Responses** + +* **200 OK**: Ping processed successfully. + + ```json + {} + ``` +* **400 Bad Request**: If the user cannot be found in the database. + + ```json + "User can't be found in our database." + ``` +* **403 Forbidden**: If the user is not authorized to perform the action. + + ```json + { + "error": "Not authorized to perform this action." + } + ``` +* **500 Internal Server Error**: Indicates an unexpected error. + + ```json + { + "error": "Failed to send friend request." + } + ``` diff --git a/friendshub/README.md b/friendshub/README.md new file mode 100644 index 0000000..c52a7a1 --- /dev/null +++ b/friendshub/README.md @@ -0,0 +1,235 @@ +--- +description: >- + Description: SignalR hub for managing real-time friend request operations, + including sending, accepting, and rejecting friend requests. +icon: wifi +--- + +# FriendsHub + +**Route** `hubs/friends` + +**Authorize**: Requires authentication (user must be logged in) + +### Hub Methods + +#### OnConnectedAsync + +**Description**: Automatically invoked when a client establishes a connection to the hub. Adds the user to their personal group for receiving friend request notifications. + +**Behavior**: + +* Retrieves the user's ID from the connection context. +* If the user ID is invalid (`Guid.Empty`), logs a warning and aborts the connection. +* Adds the user to a group named after their user ID (e.g., `userId.ToString()`). +* Logs the connection event with the user ID and connection ID. + +*** + +#### OnDisconnectedAsync + +**Description**: Automatically invoked when a client disconnects from the hub. Removes the user from their personal group. + +**Behavior**: + +* Retrieves the user's ID from the connection context, suppressing exceptions if the ID is unavailable (e.g., due to an early abort). +* If the user ID is valid, removes the user from their personal group. +* Logs the disconnection event with the user ID and connection ID. +* If an exception occurs, logs it as a warning along with the connection ID. +* If no exception occurs but the user ID is invalid, logs the disconnection with the connection ID. + +*** + +#### SendRequest + +**Description**: Sends a friend request to a specified user and notifies the recipient in real-time. + +**Request**: + +* **Method Name**: `SendRequest` +* **Parameters**: + * `targetUserId`: (`Guid`) The ID of the user to whom the friend request is sent. + +**Responses**: + +* **Success (201 Created)**: Friend request sent successfully. + + ```json + { + "status": 201, + "result": null, + "errorMessage": null + } + ``` +* **400 Bad Request**: If the operation is invalid (e.g., sending a request to oneself). + + ```json + { + "status": 400, + "result": null, + "errorMessage": "" + } + ``` +* **401 Unauthorized**: If the user is not authorized. + + ```json + { + "status": 401, + "result": null, + "errorMessage": "" + } + ``` +* **409 Conflict**: If a friend request was already sent. + + ```json + { + "status": 409, + "result": null, + "errorMessage": "" + } + ``` +* **500 Server Error**: Indicates an unexpected error. + + ```json + { + "status": 500, + "result": null, + "errorMessage": "Unexpected error! Please try later!" + } + ``` + +**Client-Side Events**: + +* **FriendRequestReceived**: Triggered on the recipient's client to notify them of a new friend request. + * Payload: `userId` (`Guid`) - The ID of the user who sent the request. + +*** + +#### AcceptRequest + +**Description**: Accepts a friend request and notifies the user in real-time. + +**Request**: + +* **Method Name**: `AcceptRequest` +* **Parameters**: + * `friendshipId`: (`Guid`) The ID of the friend request to accept. + +**Responses**: + +* **Success (200 OK)**: Friend request accepted successfully. + + ```json + { + "status": 200, + "result": null, + "errorMessage": null + } + ``` +* **400 Bad Request**: If the operation is invalid (e.g., invalid friendship ID). + + ```json + { + "status": 400, + "result": null, + "errorMessage": "" + } + ``` +* **401 Unauthorized**: If the user is not authorized to accept the request. + + ```json + { + "status": 401, + "result": null, + "errorMessage": "" + } + ``` +* **500 Server Error**: Indicates an unexpected error. + + ```json + { + "status": 500, + "result": null, + "errorMessage": "Unexpected error! Please try later!" + } + ``` + +**Client-Side Events**: + +* **FriendRequestAccepted**: Triggered on the user's client to confirm the friend request was accepted. + * Payload: `friendshipId` (`Guid`) - The ID of the accepted friend request. + +*** + +#### RejectRequest + +**Description**: Rejects a friend request and notifies the user in real-time. + +**Request**: + +* **Method Name**: `RejectRequest` +* **Parameters**: + * `friendshipId`: (`Guid`) The ID of the friend request to reject. + +**Responses**: + +* **Success (200 OK)**: Friend request rejected successfully. + + ```json + { + "status": 200, + "result": null, + "errorMessage": null + } + ``` +* **400 Bad Request**: If the operation is invalid (e.g., invalid friendship ID). + + ```json + { + "status": 400, + "result": null, + "errorMessage": "" + } + ``` +* **401 Unauthorized**: If the user is not authorized to reject the request. + + ```json + { + "status": 401, + "result": null, + "errorMessage": "" + } + ``` +* **500 Server Error**: Indicates an unexpected error. + + ```json + { + "status": 500, + "result": null, + "errorMessage": "Unexpected error! Please try later!" + } + ``` + +**Client-Side Events**: + +* **FriendRequestRejected**: Triggered on the user's client to confirm the friend request was rejected. + * Payload: `friendshipId` (`Guid`) - The ID of the rejected friend request. + +*** + +### Error Handling + +* **Invalid User ID**: If the user ID is `Guid.Empty` during connection, the connection is aborted with a logged warning. +* **Invalid Operations**: Operations like sending a request to oneself or accepting/rejecting an invalid request return a `BadRequest` result. +* **Unauthorized Access**: Unauthorized actions return an `Unauthorized` result with a logged warning. +* **Conflicts**: Attempting to send a duplicate friend request returns a `Conflict` result. +* **Unexpected Errors**: General exceptions are caught, logged, and returned as a `ServerError` result. + +*** + +### Logging + +* Logs connection and disconnection events with user ID and connection ID. +* Logs friend request operations (send, accept, reject) with success or failure details for monitoring and debugging. + +*** diff --git a/friendshub/friendshub-client-java.md b/friendshub/friendshub-client-java.md new file mode 100644 index 0000000..e9b616b --- /dev/null +++ b/friendshub/friendshub-client-java.md @@ -0,0 +1,384 @@ +--- +description: >- + Description: This sub-documentation provides example Java client code for + interacting with the FriendsHub SignalR hub using the microsoft/signalr + library. It covers connecting to the hub, handling con +icon: java +--- + +# FriendsHub Client (Java) + +**Prerequisites**: + +* Include the SignalR Java client library in your project. For Gradle, add the following dependency: + + ```gradle + implementation("com.microsoft.signalr:signalr:9.0.7") + ``` +* Ensure the client has a valid authentication token (JWT) for connecting to the hub, as it requires authentication. + +**Hub Route**: `hubs/friends`\ +**Authorize**: Requires a valid JWT token with a `userId` claim. + +### Setup and Connection + +#### Connecting to the Hub + +This example demonstrates how to establish a connection to the `FriendsHub` with authentication and handle connection events. + +```java +import com.microsoft.signalr.HubConnection; +import com.microsoft.signalr.HubConnectionBuilder; +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.Single; + +public class FriendsHubClient { + private final HubConnection hubConnection; + private final String accessToken; // JWT token with userId claim + + public FriendsHubClient(String hubUrl, String accessToken) { + this.accessToken = accessToken; + this.hubConnection = HubConnectionBuilder.create(hubUrl) + .withAccessTokenProvider(Single.just(accessToken)) + .build(); + + // Handle connection established + hubConnection.onConnected(() -> { + System.out.println("Connected to FriendsHub with connection ID: " + hubConnection.getConnectionId()); + }); + + // Handle connection closed + hubConnection.onClosed((exception) -> { + if (exception != null) { + System.err.println("Connection closed with error: " + exception.getMessage()); + } else { + System.out.println("Connection closed normally"); + } + }); + } + + public Completable startConnection() { + return hubConnection.start(); + } + + public Completable stopConnection() { + return hubConnection.stop(); + } +} +``` + +**Usage**: + +```java +String hubUrl = "https://your-api-url/hubs/friends"; +String jwtToken = "your-jwt-token-here"; +FriendsHubClient client = new FriendsHubClient(hubUrl, jwtToken); +client.startConnection() + .blockingAwait(); // Wait for connection to establish +``` + +**Notes**: + +* Replace `your-api-url` with the actual server URL. +* The `accessToken` must include a valid JWT with the `userId` claim to avoid connection abortion. +* Use `stopConnection()` to gracefully disconnect when done. + +### Subscribing to Hub Events + +The `FriendsHub` sends three client-side events: `FriendRequestReceived`, `FriendRequestAccepted`, and `FriendRequestRejected`. Below is an example of subscribing to these events. + +```java +public class FriendsHubClient { + // ... (constructor and connection methods as above) + + public void subscribeToEvents() { + // Handle FriendRequestReceived event + hubConnection.on("FriendRequestReceived", (userId) -> { + System.out.println("Received friend request from user ID: " + userId); + // Handle the friend request (e.g., update UI or notify user) + }, String.class); + + // Handle FriendRequestAccepted event + hubConnection.on("FriendRequestAccepted", (friendshipId) -> { + System.out.println("Friend request accepted, friendship ID: " + friendshipId); + // Update UI or local state to reflect new friendship + }, String.class); + + // Handle FriendRequestRejected event + hubConnection.on("FriendRequestRejected", (friendshipId) -> { + System.out.println("Friend request rejected, friendship ID: " + friendshipId); + // Update UI or notify user of rejection + }, String.class); + } +} +``` + +**Usage**: + +```java +FriendsHubClient client = new FriendsHubClient(hubUrl, jwtToken); +client.subscribeToEvents(); +client.startConnection().blockingAwait(); +``` + +**Notes**: + +* The `userId` and `friendshipId` parameters are received as strings (representing `Guid` values) due to JSON serialization. Parse them to `UUID` if needed: + + ```java + UUID uuid = UUID.fromString(userId); + ``` +* Call `subscribeToEvents()` before starting the connection to ensure event handlers are registered. + +### Invoking Hub Methods + +#### SendRequest + +Sends a friend request to another user. + +```java +public Completable sendFriendRequest(String targetUserId) { + return hubConnection.invoke(HubResult.class, "SendRequest", targetUserId) + .doOnSuccess(result -> { + switch (result.getStatus()) { + case 201: // Created + System.out.println("Friend request sent successfully"); + break; + case 400: // BadRequest + System.err.println("Bad request: " + result.getErrorMessage()); + break; + case 401: // Unauthorized + System.err.println("Unauthorized: " + result.getErrorMessage()); + break; + case 409: // Conflict + System.err.println("Conflict: " + result.getErrorMessage()); + break; + case 500: // ServerError + System.err.println("Server error: " + result.getErrorMessage()); + break; + default: + System.err.println("Unexpected status: " + result.getStatus()); + } + }) + .ignoreElement(); +} +``` + +**Usage**: + +```java +client.sendFriendRequest("target-user-guid-here") + .blockingAwait(); +``` + +**Notes**: + +* The `targetUserId` should be a valid `Guid` string (e.g., "550e8400-e29b-41d4-a716-446655440000"). +* The response is a `HubResult` with a status code and optional error message. + +#### AcceptRequest + +Accepts a friend request by its friendship ID. + +```java +public Completable acceptFriendRequest(String friendshipId) { + return hubConnection.invoke(HubResult.class, "AcceptRequest", friendshipId) + .doOnSuccess(result -> { + switch (result.getStatus()) { + case 200: // OK + System.out.println("Friend request accepted successfully"); + break; + case 400: // BadRequest + System.err.println("Bad request: " + result.getErrorMessage()); + break; + case 401: // Unauthorized + System.err.println("Unauthorized: " + result.getErrorMessage()); + break; + case 500: // ServerError + System.err.println("Server error: " + result.getErrorMessage()); + break; + default: + System.err.println("Unexpected status: " + result.getStatus()); + } + }) + .ignoreElement(); +} +``` + +**Usage**: + +```java +client.acceptFriendRequest("friendship-guid-here") + .blockingAwait(); +``` + +#### RejectRequest + +Rejects a friend request by its friendship ID. + +```java +public Completable rejectFriendRequest(String friendshipId) { + return hubConnection.invoke(HubResult.class, "RejectRequest", friendshipId) + .doOnSuccess(result -> { + switch (result.getStatus()) { + case 200: // OK + System.out.println("Friend request rejected successfully"); + break; + case 400: // BadRequest + System.err.println("Bad request: " + result.getErrorMessage()); + break; + case 401: // Unauthorized + System.err.println("Unauthorized: " + result.getErrorMessage()); + break; + case 500: // ServerError + System.err.println("Server error: " + result.getErrorMessage()); + break; + default: + System.err.println("Unexpected status: " + result.getStatus()); + } + }) + .ignoreElement(); +} +``` + +**Usage**: + +```java +client.rejectFriendRequest("friendship-guid-here") + .blockingAwait(); +``` + +### Data Model + +The client expects a `HubResult` response from hub methods, which is serialized as JSON: + +```json +{ + "status": "number", // Corresponds to HubResultStatus (e.g., 200, 400, 401, etc.) + "result": null, // Always null for FriendsHub methods + "errorMessage": "string" // Null for success, error message for failures +} +``` + +### Error Handling + +* **Connection Errors**: Handle connection failures in `onClosed` or by catching exceptions from `startConnection()`. +* **Invalid User ID**: If the JWT lacks a valid `userId` claim, the hub aborts the connection, and `onClosed` is triggered with an error. +* **Hub Method Errors**: Check the `status` and `errorMessage` fields in the `HubResult` response to handle specific errors (e.g., `BadRequest`, `Unauthorized`, `Conflict`). + +### Example Full Client + +Below is a complete example combining connection, event subscription, and method invocation. + +```java +import com.microsoft.signalr.HubConnection; +import com.microsoft.signalr.HubConnectionBuilder; +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.Single; + +public class FriendsHubClient { + private final HubConnection hubConnection; + private final String accessToken; + + public FriendsHubClient(String hubUrl, String accessToken) { + this.accessToken = accessToken; + this.hubConnection = HubConnectionBuilder.create(hubUrl) + .withAccessTokenProvider(Single.just(accessToken)) + .build(); + + // Connection events + hubConnection.onConnected(() -> { + System.out.println("Connected to FriendsHub with connection ID: " + hubConnection.getConnectionId()); + }); + hubConnection.onClosed((exception) -> { + System.err.println(exception != null ? "Connection closed with error: " + exception.getMessage() : "Connection closed normally"); + }); + + // Subscribe to hub events + hubConnection.on("FriendRequestReceived", (userId) -> { + System.out.println("Received friend request from user ID: " + userId); + }, String.class); + hubConnection.on("FriendRequestAccepted", (friendshipId) -> { + System.out.println("Friend request accepted, friendship ID: " + friendshipId); + }, String.class); + hubConnection.on("FriendRequestRejected", (friendshipId) -> { + System.out.println("Friend request rejected, friendship ID: " + friendshipId); + }, String.class); + } + + public Completable startConnection() { + return hubConnection.start(); + } + + public Completable stopConnection() { + return hubConnection.stop(); + } + + public Completable sendFriendRequest(String targetUserId) { + return hubConnection.invoke(HubResult.class, "SendRequest", targetUserId) + .doOnSuccess(this::handleHubResult) + .ignoreElement(); + } + + public Completable acceptFriendRequest(String friendshipId) { + return hubConnection.invoke(HubResult.class, "AcceptRequest", friendshipId) + .doOnSuccess(this::handleHubResult) + .ignoreElement(); + } + + public Completable rejectFriendRequest(String friendshipId) { + return hubConnection.invoke(HubResult.class, "RejectRequest", friendshipId) + .doOnSuccess(this::handleHubResult) + .ignoreElement(); + } + + private void handleHubResult(HubResult result) { + switch (result.getStatus()) { + case 200: + System.out.println("Operation successful"); + break; + case 201: + System.out.println("Resource created successfully"); + break; + case 400: + System.err.println("Bad request: " + result.getErrorMessage()); + break; + case 401: + System.err.println("Unauthorized: " + result.getErrorMessage()); + break; + case 409: + System.err.println("Conflict: " + result.getErrorMessage()); + break; + case 500: + System.err.println("Server error: " + result.getErrorMessage()); + break; + default: + System.err.println("Unexpected status: " + result.getStatus()); + } + } + + public static void main(String[] args) { + String hubUrl = "https://your-api-url/hubs/friends"; + String jwtToken = "your-jwt-token-here"; + FriendsHubClient client = new FriendsHubClient(hubUrl, jwtToken); + + // Start connection and send a friend request + client.startConnection() + .andThen(client.sendFriendRequest("550e8400-e29b-41d4-a716-446655440000")) + .blockingAwait(); + + // Simulate accepting a friend request + client.acceptFriendRequest("friendship-guid-here") + .blockingAwait(); + + // Stop connection + client.stopConnection().blockingAwait(); + } +} +``` + +**Notes**: + +* Replace `your-api-url` and `your-jwt-token-here` with actual values. +* The `main` method demonstrates a complete workflow, but in a real application, you would integrate this with your UI or event loop. +* Use `UUID.fromString()` to convert string GUIDs to `UUID` objects if needed for internal logic. diff --git a/signalr/chathub.md b/signalr/chathub.md new file mode 100644 index 0000000..368938f --- /dev/null +++ b/signalr/chathub.md @@ -0,0 +1,162 @@ +--- +icon: wifi +--- + +# ChatHub + +**Route**: `hubs/chats`\ +**Authorize**: Requires authentication (user must be logged in) + +*** + +### Hub Methods + +#### OnConnectedAsync + +**Description**: Automatically invoked when a client establishes a connection to the hub. Adds the user to their personal group for private messages and notifications. + +**Behavior**: + +* Retrieves the user's ID from the connection context. +* If the user ID is invalid (`Guid.Empty`), logs a warning and aborts the connection. +* Adds the user to a group named after their user ID (e.g., `userId.ToString()`). +* Logs the connection event with the user ID and connection ID. + +**Notes**: + +* Future enhancements may include adding the user to their chat groups (currently a TODO in the code). + +*** + +#### OnDisconnectedAsync + +**Description**: Automatically invoked when a client disconnects from the hub. Removes the user from their personal group. + +**Behavior**: + +* Retrieves the user's ID from the connection context, suppressing exceptions if the ID is unavailable (e.g., due to an early abort). +* If the user ID is valid, removes the user from their personal group. +* Logs the disconnection event with the user ID and connection ID. +* If an exception occurs, logs it as a warning along with the connection ID. +* If no exception occurs but the user ID is invalid, logs the disconnection with the connection ID. + +**Notes**: + +* Future enhancements may include removing the user from their chat groups (currently a TODO in the code). + +*** + +#### Send + +**Description**: Allows a client to send a message to a recipient, which can be either a user or a group. + +**Request**: + +* **Method Name**: `Send` +* **Parameters**: + * `request`: An object of type `MessageRequest` with the following structure: + + ```json + { + "encryptedContent": "string", + "replyToMessageId": "Guid", + "recipientId": "Guid", + "recipientType": "string", // "User" or "Group" + "mediaAttachments": [ + { + "mediaId": "Guid", + "encryptedKey": "string", + "type": "string", + "mimeType": "string" + } + ] + } + ``` + +**Responses**: + +* **Success**: + * Sends the message to the recipient (user or group) via the `ReceiveMessage` event. + * Notifies the sender with a confirmation via the `MessageSent` event. + * Logs the successful message send with the message ID, sender ID, recipient ID, and recipient type. +* **Error**: + * If the message is empty (no `encryptedContent` and no `mediaAttachments`), logs a warning and throws an `ArgumentException`. + * If the message fails to send (e.g., due to an internal error), logs the error and throws a `HubException`. + +**Client-Side Events**: + +* **ReceiveMessage**: Triggered on the recipient's client(s) to deliver the message. + * Payload: `UserMessageResponse` object. +* **MessageSent**: Triggered on the sender's client to confirm the message was sent. + * Payload: `UserMessageResponse` object. + +**Notes**: + +* The message must contain either `encryptedContent` or `mediaAttachments`; otherwise, it is invalid. +* The recipient is determined by `recipientType`: + * `"User"`: Sends to the recipient’s personal group (e.g., `recipientId.ToString()`). + * `"Group"`: Sends to all members of the group (e.g., `group_{recipientId}`). + +*** + +### Data Models + +#### MessageRequest + +```json +{ + "encryptedContent": "string", + "replyToMessageId": "Guid", + "recipientId": "Guid", + "recipientType": "string", // "User" or "Group" + "mediaAttachments": [ + { + "mediaId": "Guid", + "encryptedKey": "string", + "type": "string", + "mimeType": "string" + } + ] +} +``` + +#### UserMessageResponse + +```json +{ + "messageId": "Guid", + "senderId": "Guid", + "recipientId": "Guid", + "recipientType": "string", + "encryptedContent": "string", + "sentAt": "DateTime", + "isEdited": "bool", + "mediaAttachments": [ + { + "mediaId": "Guid", + "encryptedKey": "string", + "type": "string", + "mimeType": "string" + } + ], + "replyToMessageId": "Guid" +} +``` + +*** + +### Error Handling + +* **Invalid User ID**: If the user ID is `Guid.Empty` during connection, the connection is aborted with a logged warning. +* **Empty Message**: If a message lacks both content and attachments, an `ArgumentException` is thrown with a logged warning. +* **Message Send Failure**: If sending fails (e.g., due to a service error), a `HubException` is thrown with the error logged. + +*** + +### Logging + +* Logs connection events with user ID and connection ID. +* Logs disconnection events, including exceptions if present. +* Logs message send attempts, successes, and failures for monitoring and debugging. + +*** diff --git a/signalr/presencehub.md b/signalr/presencehub.md new file mode 100644 index 0000000..eb29710 --- /dev/null +++ b/signalr/presencehub.md @@ -0,0 +1,53 @@ +--- +description: SignalR hub for managing user online presence and notifications. +icon: wifi +--- + +# PresenceHub + +### Controller Description + +* **Route**: `hubs/presence` +* **Authorize**: Requires authenticated user with roles "Admin" or "User" (`[Authorize(Roles = "Admin, User")]`). + +### Hub Methods + +#### OnConnectedAsync + +* **Description**: Invoked when a client connects, updates online status and notifies friends. +* **Behavior**: + * Retrieves user ID from context. + * Aborts connection if user ID is `Guid.Empty` or user does not exist. + * Adds user to online store and their personal group. + * Notifies friends of user going online via `UserOnline` event. +* **Client-Side Events**: + * **UserOnline**: Triggered for friends with payload `userId` (Guid). +* **Responses**: None (connection aborted on invalid user ID). + +#### OnDisconnectedAsync + +* **Description**: Invoked when a client disconnects, updates offline status and notifies friends. +* **Behavior**: + * Retrieves user ID from context, suppressing exceptions. + * Removes user from personal group if ID is valid. + * Updates user's `WasOnline` timestamp. + * Sets user as offline in store. + * Notifies friends of user going offline via `UserOffline` event. +* **Client-Side Events**: + * **UserOffline**: Triggered for friends with payload `userId` (Guid). +* **Responses**: None. + +### Data Models + +* **N/A**: No specific data models defined for hub methods. + +### Error Handling + +* **Invalid User ID**: Aborts connection with logged warning if `Guid.Empty` or user not found. +* **Unexpected Errors**: Logged, but no specific response (handled by base method). + +### Logging + +* Logs warnings for invalid user ID on connection. +* Logs information for disconnections with user ID and connection ID. +* Logs errors for unexpected exceptions during disconnection. From 15f9370dbe765351751d5006007087dfbc2d087d Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Thu, 24 Jul 2025 17:03:51 +0700 Subject: [PATCH 070/101] Created SessionController and UserSessionReader + tests for UserSessionReader --- .../ConfigurationProgramExtensions.cs | 2 + Govor.API/Common/Mapping/MappingProfile.cs | 2 + Govor.API/Controllers/SessionController.cs | 109 ++++++++++++++++++ .../UserSessions/UserSessionReaderTests.cs | 66 +++++++++++ .../UserSession/IUserSessionRevoker.cs | 2 +- .../UserSessions/UserSessionReader.cs | 34 ++++++ Govor.Contracts/DTOs/SessionDto.cs | 10 ++ 7 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 Govor.API/Controllers/SessionController.cs create mode 100644 Govor.Application.Tests/Services/UserSessions/UserSessionReaderTests.cs create mode 100644 Govor.Application/Services/UserSessions/UserSessionReader.cs create mode 100644 Govor.Contracts/DTOs/SessionDto.cs diff --git a/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs index 6a1602a..8233328 100644 --- a/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs +++ b/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs @@ -87,6 +87,8 @@ public static class ConfigurationProgramExtensions services.AddAutoMapper(typeof(MappingProfile)); services.AddScoped(); + + services.AddScoped(); } public static void AddRepositories(this IServiceCollection services) diff --git a/Govor.API/Common/Mapping/MappingProfile.cs b/Govor.API/Common/Mapping/MappingProfile.cs index 5b15e9e..9f94bda 100644 --- a/Govor.API/Common/Mapping/MappingProfile.cs +++ b/Govor.API/Common/Mapping/MappingProfile.cs @@ -21,5 +21,7 @@ public class MappingProfile : Profile .AfterMap(); CreateMap(); + + CreateMap(); } } \ No newline at end of file diff --git a/Govor.API/Controllers/SessionController.cs b/Govor.API/Controllers/SessionController.cs new file mode 100644 index 0000000..ae600ee --- /dev/null +++ b/Govor.API/Controllers/SessionController.cs @@ -0,0 +1,109 @@ +using AutoMapper; +using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.Application.Interfaces.UserSession; +using Govor.Contracts.DTOs; +using Govor.Data.Repositories.Exceptions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Govor.API.Controllers; + +[ApiController] +[Authorize(Roles = "Admin,User")] +[Route("api/[controller]")] +public class SessionController : Controller +{ + private readonly ILogger _logger; + private readonly ICurrentUserService _currentUserService; + private readonly IUserSessionReader _userSessionReader; + private readonly IUserSessionRevoker _userSessionRevoker; + private readonly IMapper _mapper; + + public SessionController( + ILogger logger, + ICurrentUserService currentUserService, + IUserSessionReader userSessionReader, + IUserSessionRevoker userSessionRevoker, + IMapper mapper) + { + _logger = logger; + _currentUserService = currentUserService; + _userSessionReader = userSessionReader; + _userSessionRevoker = userSessionRevoker; + _mapper = mapper; + } + + [HttpGet("all")] + public async Task GetAllSessions() + { + try + { + var sessions = await _userSessionReader.GetAllSessionsAsync(_currentUserService.GetCurrentUserId()); + return Ok(_mapper.Map>(sessions)); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, ex.Message); + return Forbid(ex.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + return StatusCode(500, "Unexpected Error! Please try again later."); + } + } + + [HttpDelete("close/{sessionId}")] + public async Task CloseSession(Guid sessionId) + { + try + { + if (sessionId == Guid.Empty) + return BadRequest("Invalid sessionId."); + + await _userSessionRevoker.CloseSessionByIdAsync(sessionId, + _currentUserService.GetCurrentUserId()); + return Ok(); + } + catch (InvalidOperationException ex) + { + _logger.LogError(ex, ex.Message); + return BadRequest(ex.Message); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, ex.Message); + return Forbid(ex.Message); + } + catch (NotFoundException ex) + { + _logger.LogWarning(ex, ex.Message); + return NotFound(ex.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + return StatusCode(500, "Unexpected Error! Please try again later."); + } + } + + [HttpDelete("close/all")] + public async Task CloseAllSessions() + { + try + { + await _userSessionRevoker.CloseAllSessionsAsync(_currentUserService.GetCurrentUserId()); + return Ok(); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, ex.Message); + return Forbid(ex.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + return StatusCode(500, "Unexpected Error! Please try again later."); + } + } +} \ No newline at end of file diff --git a/Govor.Application.Tests/Services/UserSessions/UserSessionReaderTests.cs b/Govor.Application.Tests/Services/UserSessions/UserSessionReaderTests.cs new file mode 100644 index 0000000..145c2d6 --- /dev/null +++ b/Govor.Application.Tests/Services/UserSessions/UserSessionReaderTests.cs @@ -0,0 +1,66 @@ +using AutoFixture; +using Govor.Application.Interfaces.UserSession; +using Govor.Application.Services.UserSessions; +using Govor.Core.Models; +using Govor.Core.Repositories.UserSessionsRepository; +using Govor.Data.Repositories.Exceptions; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Govor.Application.Tests.Services.UserSessions; + +[TestFixture] +[TestOf(typeof(UserSessionReader))] +public class UserSessionReaderTests +{ + private Mock _mockUserSessionsRepository; + private Mock> _mockLogger; + private Fixture _fixture; + private IUserSessionReader _userSessionReader; + + [SetUp] + public void SetUp() + { + _fixture = new Fixture(); + + _mockUserSessionsRepository = new Mock(); + _mockLogger = new Mock>(); + + _userSessionReader = new UserSessionReader(_mockUserSessionsRepository.Object, + _mockLogger.Object); + } + + [Test] + public async Task GetAllUserSessionsAsync_ShouldReturnAllUserSessions() + { + // Arrange + var sessios = _fixture.CreateMany().ToList(); + var userId = Guid.NewGuid(); + + _mockUserSessionsRepository.Setup(f => f.GetByUserIdAsync(userId)) + .ReturnsAsync(sessios); + // Act + var result = await _userSessionReader.GetAllSessionsAsync(userId); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result, Is.EquivalentTo(sessios)); + } + + [Test] + public async Task GetAllUserSessionsAsync_ThrowsNotFoundByKeyException_ReturnsEmptyList() + { + // Arrange + var userId = Guid.NewGuid(); + + _mockUserSessionsRepository.Setup(f => f.GetByUserIdAsync(userId)) + .ThrowsAsync(new NotFoundByKeyException(userId)); + + // Act + var result = await _userSessionReader.GetAllSessionsAsync(userId); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result, Is.Empty); + } +} \ No newline at end of file diff --git a/Govor.Application/Interfaces/UserSession/IUserSessionRevoker.cs b/Govor.Application/Interfaces/UserSession/IUserSessionRevoker.cs index 6106db5..af256fa 100644 --- a/Govor.Application/Interfaces/UserSession/IUserSessionRevoker.cs +++ b/Govor.Application/Interfaces/UserSession/IUserSessionRevoker.cs @@ -3,6 +3,6 @@ namespace Govor.Application.Interfaces.UserSession; public interface IUserSessionRevoker { - Task CloseSessionByRefreshTokenAsync(string refreshToken); + Task CloseSessionByIdAsync(Guid sessionId, Guid userId); Task CloseAllSessionsAsync(Guid userId); } diff --git a/Govor.Application/Services/UserSessions/UserSessionReader.cs b/Govor.Application/Services/UserSessions/UserSessionReader.cs new file mode 100644 index 0000000..32c9600 --- /dev/null +++ b/Govor.Application/Services/UserSessions/UserSessionReader.cs @@ -0,0 +1,34 @@ +using Govor.Application.Interfaces.UserSession; +using Govor.Core.Models; +using Govor.Core.Repositories.UserSessionsRepository; +using Govor.Data.Repositories.Exceptions; +using Microsoft.Extensions.Logging; + +namespace Govor.Application.Services.UserSessions; + +public class UserSessionReader : IUserSessionReader +{ + private readonly IUserSessionsRepository _repository; + private readonly ILogger _logger; + + public UserSessionReader(IUserSessionsRepository repository, ILogger logger) + { + _repository = repository; + _logger = logger; + } + + public async Task> GetAllSessionsAsync(Guid userId) + { + try + { + _logger.LogInformation($"Getting all sessions for user {userId}"); + var sessions = await _repository.GetByUserIdAsync(userId); + return sessions; + } + catch (NotFoundByKeyException ex) + { + _logger.LogWarning("The user has no active sessions."); + return []; + } + } +} \ No newline at end of file diff --git a/Govor.Contracts/DTOs/SessionDto.cs b/Govor.Contracts/DTOs/SessionDto.cs new file mode 100644 index 0000000..f0b8a8d --- /dev/null +++ b/Govor.Contracts/DTOs/SessionDto.cs @@ -0,0 +1,10 @@ +namespace Govor.Contracts.DTOs; + +public record SessionDto +{ + public Guid Id { get; init; } + public string DeviceInfo { get; init; } = string.Empty; + public DateTime CreatedAt { get; init; } + public DateTime ExpiresAt { get; init; } + public bool IsRevoked { get; init; } +} From 6a6c3d513d082b10b5f666c6a9e95d28f1ece391 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Thu, 24 Jul 2025 18:05:11 +0700 Subject: [PATCH 071/101] UserSessionRevoker --- .../ConfigurationProgramExtensions.cs | 1 + Govor.API/appsettings.json | 2 +- .../UserSessions/UserSessionReader.cs | 5 +- .../UserSessions/UserSessionRevoker.cs | 63 +++++++++++++++++++ 4 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 Govor.Application/Services/UserSessions/UserSessionRevoker.cs diff --git a/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs index 8233328..4795c58 100644 --- a/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs +++ b/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs @@ -89,6 +89,7 @@ public static class ConfigurationProgramExtensions services.AddScoped(); services.AddScoped(); + services.AddScoped(); } public static void AddRepositories(this IServiceCollection services) diff --git a/Govor.API/appsettings.json b/Govor.API/appsettings.json index d01d102..7bdb15b 100644 --- a/Govor.API/appsettings.json +++ b/Govor.API/appsettings.json @@ -12,7 +12,7 @@ "AllowedHosts": "govor-team-govor-88b3.twc1.net;localhost;localhost:7155", "JwtAccessOption": { "SecretKey": "Q89eY7zP7C4+TqLmHF4kw9xkF1E8Ru4Zpg+up9wFt9g=", - "Minutes": 25 + "Minutes": 5 }, "JwtRefreshOption": { "RefreshTokenLifetimeDays": 30 diff --git a/Govor.Application/Services/UserSessions/UserSessionReader.cs b/Govor.Application/Services/UserSessions/UserSessionReader.cs index 32c9600..cd93052 100644 --- a/Govor.Application/Services/UserSessions/UserSessionReader.cs +++ b/Govor.Application/Services/UserSessions/UserSessionReader.cs @@ -23,11 +23,12 @@ public class UserSessionReader : IUserSessionReader { _logger.LogInformation($"Getting all sessions for user {userId}"); var sessions = await _repository.GetByUserIdAsync(userId); - return sessions; + + return sessions.Where(f => !f.IsRevoked).ToList() ?? []; } catch (NotFoundByKeyException ex) { - _logger.LogWarning("The user has no active sessions."); + _logger.LogWarning("The user has no sessions."); return []; } } diff --git a/Govor.Application/Services/UserSessions/UserSessionRevoker.cs b/Govor.Application/Services/UserSessions/UserSessionRevoker.cs new file mode 100644 index 0000000..6eb6450 --- /dev/null +++ b/Govor.Application/Services/UserSessions/UserSessionRevoker.cs @@ -0,0 +1,63 @@ +using Govor.Application.Interfaces.UserSession; +using Govor.Core.Repositories.UserSessionsRepository; +using Govor.Data.Repositories.Exceptions; +using Microsoft.Extensions.Logging; + +namespace Govor.Application.Services.UserSessions; + +public class UserSessionRevoker : IUserSessionRevoker +{ + private readonly IUserSessionsRepository _sessionsRepository; + private readonly ILogger _logger; + + public UserSessionRevoker(IUserSessionsRepository sessionsRepository, ILogger logger) + { + _sessionsRepository = sessionsRepository; + _logger = logger; + } + + public async Task CloseSessionByIdAsync(Guid sessionId, Guid userId) + { + try + { + var session = await _sessionsRepository.GetByIdAsync(sessionId); + + if (session.UserId != userId) + { + _logger.LogWarning("User {userId} does not belong to this session {sessionId}", userId, sessionId); + throw new UnauthorizedAccessException($"User {userId} does not belong to this session {sessionId}"); + } + + if(session.IsRevoked) return; + + session.IsRevoked = true; + await _sessionsRepository.UpdateAsync(session); + } + catch (NotFoundByKeyException ex) + { + _logger.LogWarning("User {userId} tried close unreal session!", userId); + throw new NotFoundException("Session not found", ex); + } + } + + public async Task CloseAllSessionsAsync(Guid userId) + { + try + { + var sessions = await _sessionsRepository.GetByUserIdAsync(userId); + + foreach (var session in sessions) + { + if(session.IsRevoked) continue; + + session.IsRevoked = true; + await _sessionsRepository.UpdateAsync(session); + } + } + catch (NotFoundByKeyException ex) + { + _logger.LogWarning("User {userId} has not sessions", userId); + throw new NotFoundException("Session not found", ex); + } + } +} \ No newline at end of file From a0ef087110bda7bacfff0c7e0653eb0d1923c90c Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Thu, 24 Jul 2025 18:29:35 +0700 Subject: [PATCH 072/101] SessionControllerTests --- .../Controllers/SessionControllerTests.cs | 278 ++++++++++++++++++ .../UserSessions/UserSessionReaderTests.cs | 5 +- .../UserSessions/UserSessionRevokerTests.cs | 152 ++++++++++ Govor.Core/Models/GroupMembership.cs | 1 + 4 files changed, 435 insertions(+), 1 deletion(-) create mode 100644 Govor.API.Tests/Controllers/SessionControllerTests.cs create mode 100644 Govor.Application.Tests/Services/UserSessions/UserSessionRevokerTests.cs diff --git a/Govor.API.Tests/Controllers/SessionControllerTests.cs b/Govor.API.Tests/Controllers/SessionControllerTests.cs new file mode 100644 index 0000000..3b95be3 --- /dev/null +++ b/Govor.API.Tests/Controllers/SessionControllerTests.cs @@ -0,0 +1,278 @@ +using AutoMapper; +using Govor.API.Controllers; +using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.Application.Interfaces.UserSession; +using Govor.Contracts.DTOs; +using Govor.Core.Models; +using Govor.Data.Repositories.Exceptions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Govor.API.Tests.Controllers; + +[TestFixture] +[TestOf(typeof(SessionController))] +public class SessionControllerTests +{ + private Mock> _loggerMock; + private Mock _currentUserServiceMock; + private Mock _userSessionReaderMock; + private Mock _userSessionRevokerMock; + private Mock _mapperMock; + private SessionController _controller; + + [SetUp] + public void SetUp() + { + _loggerMock = new Mock>(); + _currentUserServiceMock = new Mock(); + _userSessionReaderMock = new Mock(); + _userSessionRevokerMock = new Mock(); + _mapperMock = new Mock(); + _controller = new SessionController( + _loggerMock.Object, + _currentUserServiceMock.Object, + _userSessionReaderMock.Object, + _userSessionRevokerMock.Object, + _mapperMock.Object); + } + + [Test] + public async Task GetAllSessions_Successful_ReturnsOkWithMappedSessions() + { + // Arrange + var userId = Guid.NewGuid(); + var sessions = new List { new UserSession { Id = Guid.NewGuid(), UserId = userId } }; + var sessionDtos = new List { new SessionDto { Id = sessions[0].Id } }; + _currentUserServiceMock.Setup(s => s.GetCurrentUserId()).Returns(userId); + _userSessionReaderMock.Setup(r => r.GetAllSessionsAsync(userId)).ReturnsAsync(sessions); + _mapperMock.Setup(m => m.Map>(sessions)).Returns(sessionDtos); + + // Act + var result = await _controller.GetAllSessions(); + + // Assert + Assert.That(result, Is.TypeOf()); + var okResult = result as OkObjectResult; + Assert.That(okResult.Value, Is.EqualTo(sessionDtos)); + _loggerMock.VerifyNoOtherCalls(); + } + + [Test] + public async Task GetAllSessions_UnauthorizedAccess_ReturnsForbid() + { + // Arrange + var userId = Guid.NewGuid(); + var exception = new UnauthorizedAccessException("Unauthorized"); + _currentUserServiceMock.Setup(s => s.GetCurrentUserId()).Returns(userId); + _userSessionReaderMock.Setup(r => r.GetAllSessionsAsync(userId)).ThrowsAsync(exception); + + // Act + var result = await _controller.GetAllSessions(); + + // Assert + Assert.That(result, Is.TypeOf()); + _loggerMock.Verify(l => l.Log( + LogLevel.Warning, + It.IsAny(), + It.IsAny(), + exception, + It.IsAny>()), Times.Once()); + } + + [Test] + public async Task GetAllSessions_UnexpectedError_Returns500() + { + // Arrange + var userId = Guid.NewGuid(); + var exception = new Exception("Unexpected error"); + _currentUserServiceMock.Setup(s => s.GetCurrentUserId()).Returns(userId); + _userSessionReaderMock.Setup(r => r.GetAllSessionsAsync(userId)).ThrowsAsync(exception); + + // Act + var result = await _controller.GetAllSessions(); + + // Assert + Assert.That(result, Is.TypeOf()); + var statusResult = result as ObjectResult; + Assert.That(statusResult.StatusCode, Is.EqualTo(500)); + Assert.That(statusResult.Value, Is.EqualTo("Unexpected Error! Please try again later.")); + _loggerMock.Verify(l => l.Log( + LogLevel.Error, + It.IsAny(), + It.IsAny(), + exception, + It.IsAny>()), Times.Once()); + } + + [Test] + public async Task CloseSession_ValidSessionId_ReturnsOk() + { + // Arrange + var userId = Guid.NewGuid(); + var sessionId = Guid.NewGuid(); + _currentUserServiceMock.Setup(s => s.GetCurrentUserId()).Returns(userId); + + // Act + var result = await _controller.CloseSession(sessionId); + + // Assert + Assert.That(result, Is.TypeOf()); + _userSessionRevokerMock.Verify(r => r.CloseSessionByIdAsync(sessionId, userId), Times.Once()); + _loggerMock.VerifyNoOtherCalls(); + } + + [Test] + public async Task CloseSession_EmptySessionId_ReturnsBadRequest() + { + // Arrange + var sessionId = Guid.Empty; + + // Act + var result = await _controller.CloseSession(sessionId); + + // Assert + Assert.That(result, Is.TypeOf()); + var badRequestResult = result as BadRequestObjectResult; + Assert.That(badRequestResult.Value, Is.EqualTo("Invalid sessionId.")); + _userSessionRevokerMock.Verify(r => r.CloseSessionByIdAsync(It.IsAny(), It.IsAny()), Times.Never()); + _loggerMock.VerifyNoOtherCalls(); + } + + [Test] + public async Task CloseSession_UnauthorizedAccess_ReturnsForbid() + { + // Arrange + var userId = Guid.NewGuid(); + var sessionId = Guid.NewGuid(); + var exception = new UnauthorizedAccessException("Unauthorized"); + _currentUserServiceMock.Setup(s => s.GetCurrentUserId()).Returns(userId); + _userSessionRevokerMock.Setup(r => r.CloseSessionByIdAsync(sessionId, userId)).ThrowsAsync(exception); + + // Act + var result = await _controller.CloseSession(sessionId); + + // Assert + Assert.That(result, Is.TypeOf()); + _loggerMock.Verify(l => l.Log( + LogLevel.Warning, + It.IsAny(), + It.IsAny(), + exception, + It.IsAny>()), Times.Once()); + } + + [Test] + public async Task CloseSession_NotFound_ReturnsNotFound() + { + // Arrange + var userId = Guid.NewGuid(); + var sessionId = Guid.NewGuid(); + var exception = new NotFoundException("Session not found"); + _currentUserServiceMock.Setup(s => s.GetCurrentUserId()).Returns(userId); + _userSessionRevokerMock.Setup(r => r.CloseSessionByIdAsync(sessionId, userId)).ThrowsAsync(exception); + + // Act + var result = await _controller.CloseSession(sessionId); + + // Assert + Assert.That(result, Is.TypeOf()); + var notFoundResult = result as NotFoundObjectResult; + Assert.That(notFoundResult.Value, Is.EqualTo("Session not found")); + _loggerMock.Verify(l => l.Log( + LogLevel.Warning, + It.IsAny(), + It.IsAny(), + exception, + It.IsAny>()), Times.Once()); + } + + [Test] + public async Task CloseSession_InvalidOperation_ReturnsBadRequest() + { + // Arrange + var userId = Guid.NewGuid(); + var sessionId = Guid.NewGuid(); + var exception = new InvalidOperationException("Invalid operation"); + _currentUserServiceMock.Setup(s => s.GetCurrentUserId()).Returns(userId); + _userSessionRevokerMock.Setup(r => r.CloseSessionByIdAsync(sessionId, userId)).ThrowsAsync(exception); + + // Act + var result = await _controller.CloseSession(sessionId); + + // Assert + Assert.That(result, Is.TypeOf()); + var badRequestResult = result as BadRequestObjectResult; + Assert.That(badRequestResult.Value, Is.EqualTo("Invalid operation")); + _loggerMock.Verify(l => l.Log( + LogLevel.Error, + It.IsAny(), + It.IsAny(), + exception, + It.IsAny>()), Times.Once()); + } + + [Test] + public async Task CloseAllSessions_Successful_ReturnsOk() + { + // Arrange + var userId = Guid.NewGuid(); + _currentUserServiceMock.Setup(s => s.GetCurrentUserId()).Returns(userId); + + // Act + var result = await _controller.CloseAllSessions(); + + // Assert + Assert.That(result, Is.TypeOf()); + _userSessionRevokerMock.Verify(r => r.CloseAllSessionsAsync(userId), Times.Once()); + _loggerMock.VerifyNoOtherCalls(); + } + + [Test] + public async Task CloseAllSessions_UnauthorizedAccess_ReturnsForbid() + { + // Arrange + var userId = Guid.NewGuid(); + var exception = new UnauthorizedAccessException("Unauthorized"); + _currentUserServiceMock.Setup(s => s.GetCurrentUserId()).Returns(userId); + _userSessionRevokerMock.Setup(r => r.CloseAllSessionsAsync(userId)).ThrowsAsync(exception); + + // Act + var result = await _controller.CloseAllSessions(); + + // Assert + Assert.That(result, Is.TypeOf()); + _loggerMock.Verify(l => l.Log( + LogLevel.Warning, + It.IsAny(), + It.IsAny(), + exception, + It.IsAny>()), Times.Once()); + } + + [Test] + public async Task CloseAllSessions_UnexpectedError_Returns500() + { + // Arrange + var userId = Guid.NewGuid(); + var exception = new Exception("Unexpected error"); + _currentUserServiceMock.Setup(s => s.GetCurrentUserId()).Returns(userId); + _userSessionRevokerMock.Setup(r => r.CloseAllSessionsAsync(userId)).ThrowsAsync(exception); + + // Act + var result = await _controller.CloseAllSessions(); + + // Assert + Assert.That(result, Is.TypeOf()); + var statusResult = result as ObjectResult; + Assert.That(statusResult.StatusCode, Is.EqualTo(500)); + Assert.That(statusResult.Value, Is.EqualTo("Unexpected Error! Please try again later.")); + _loggerMock.Verify(l => l.Log( + LogLevel.Error, + It.IsAny(), + It.IsAny(), + exception, + It.IsAny>()), Times.Once()); + } +} \ No newline at end of file diff --git a/Govor.Application.Tests/Services/UserSessions/UserSessionReaderTests.cs b/Govor.Application.Tests/Services/UserSessions/UserSessionReaderTests.cs index 145c2d6..1b8e698 100644 --- a/Govor.Application.Tests/Services/UserSessions/UserSessionReaderTests.cs +++ b/Govor.Application.Tests/Services/UserSessions/UserSessionReaderTests.cs @@ -34,7 +34,10 @@ public class UserSessionReaderTests public async Task GetAllUserSessionsAsync_ShouldReturnAllUserSessions() { // Arrange - var sessios = _fixture.CreateMany().ToList(); + var sessios = _fixture.Build() + .With(f => f.IsRevoked, false) + .CreateMany().ToList(); + var userId = Guid.NewGuid(); _mockUserSessionsRepository.Setup(f => f.GetByUserIdAsync(userId)) diff --git a/Govor.Application.Tests/Services/UserSessions/UserSessionRevokerTests.cs b/Govor.Application.Tests/Services/UserSessions/UserSessionRevokerTests.cs new file mode 100644 index 0000000..26066ee --- /dev/null +++ b/Govor.Application.Tests/Services/UserSessions/UserSessionRevokerTests.cs @@ -0,0 +1,152 @@ +using Govor.Application.Services.UserSessions; +using Govor.Core.Models; +using Govor.Core.Repositories.UserSessionsRepository; +using Govor.Data.Repositories.Exceptions; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Govor.Application.Tests.Services.UserSessions; + +[TestFixture] +[TestOf(typeof(UserSessionRevoker))] +public class UserSessionRevokerTests +{ + private Mock _sessionsRepositoryMock; + private Mock> _loggerMock; + private UserSessionRevoker _revoker; + + [SetUp] + public void SetUp() + { + _sessionsRepositoryMock = new Mock(); + _loggerMock = new Mock>(); + _revoker = new UserSessionRevoker(_sessionsRepositoryMock.Object, _loggerMock.Object); + } + + [Test] + public async Task CloseSessionByIdAsync_ValidSessionAndUserId_SetsIsRevokedAndUpdates() + { + // Arrange + var sessionId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var session = new UserSession { Id = sessionId, UserId = userId, IsRevoked = false }; + _sessionsRepositoryMock.Setup(r => r.GetByIdAsync(sessionId)).ReturnsAsync(session); + + // Act + await _revoker.CloseSessionByIdAsync(sessionId, userId); + + // Assert + Assert.That(session.IsRevoked, Is.True); + _sessionsRepositoryMock.Verify(r => r.UpdateAsync(session), Times.Once()); + _loggerMock.VerifyNoOtherCalls(); + } + + [Test] + public void CloseSessionByIdAsync_UnauthorizedUser_ThrowsUnauthorizedAccessException() + { + // Arrange + var sessionId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var differentUserId = Guid.NewGuid(); + var session = new UserSession { Id = sessionId, UserId = userId, IsRevoked = false }; + _sessionsRepositoryMock.Setup(r => r.GetByIdAsync(sessionId)).ReturnsAsync(session); + + // Act & Assert + var ex = Assert.ThrowsAsync(() => + _revoker.CloseSessionByIdAsync(sessionId, differentUserId)); + Assert.That(ex.Message, Contains.Substring($"User {differentUserId} does not belong to this session {sessionId}")); + _loggerMock.Verify(l => l.Log( + LogLevel.Warning, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>()), Times.Once()); + _sessionsRepositoryMock.Verify(r => r.UpdateAsync(It.IsAny()), Times.Never()); + } + + [Test] + public async Task CloseSessionByIdAsync_AlreadyRevokedSession_DoesNotUpdate() + { + // Arrange + var sessionId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var session = new UserSession { Id = sessionId, UserId = userId, IsRevoked = true }; + _sessionsRepositoryMock.Setup(r => r.GetByIdAsync(sessionId)).ReturnsAsync(session); + + // Act + await _revoker.CloseSessionByIdAsync(sessionId, userId); + + // Assert + _sessionsRepositoryMock.Verify(r => r.UpdateAsync(It.IsAny()), Times.Never()); + _loggerMock.VerifyNoOtherCalls(); + } + + [Test] + public void CloseSessionByIdAsync_NonExistentSession_ThrowsNotFoundException() + { + // Arrange + var sessionId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + _sessionsRepositoryMock.Setup(r => r.GetByIdAsync(sessionId)) + .ThrowsAsync(new NotFoundByKeyException(sessionId)); + + // Act & Assert + var ex = Assert.ThrowsAsync(() => + _revoker.CloseSessionByIdAsync(sessionId, userId)); + Assert.That(ex.Message, Is.EqualTo("Session not found")); + _loggerMock.Verify(l => l.Log( + LogLevel.Warning, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>()), Times.Once()); + _sessionsRepositoryMock.Verify(r => r.UpdateAsync(It.IsAny()), Times.Never()); + } + + [Test] + public async Task CloseAllSessionsAsync_MultipleSessions_RevokesNonRevokedSessions() + { + // Arrange + var userId = Guid.NewGuid(); + var sessions = new List + { + new UserSession { Id = Guid.NewGuid(), UserId = userId, IsRevoked = false }, + new UserSession { Id = Guid.NewGuid(), UserId = userId, IsRevoked = true }, + new UserSession { Id = Guid.NewGuid(), UserId = userId, IsRevoked = false } + }; + _sessionsRepositoryMock.Setup(r => r.GetByUserIdAsync(userId)).ReturnsAsync(sessions); + + // Act + await _revoker.CloseAllSessionsAsync(userId); + + // Assert + Assert.That(sessions[0].IsRevoked, Is.True); + Assert.That(sessions[1].IsRevoked, Is.True); + Assert.That(sessions[2].IsRevoked, Is.True); + _sessionsRepositoryMock.Verify(r => r.UpdateAsync(sessions[0]), Times.Once()); + _sessionsRepositoryMock.Verify(r => r.UpdateAsync(sessions[2]), Times.Once()); + _sessionsRepositoryMock.Verify(r => r.UpdateAsync(sessions[1]), Times.Never()); + _loggerMock.VerifyNoOtherCalls(); + } + + [Test] + public void CloseAllSessionsAsync_NoSessions_ThrowsNotFoundException() + { + // Arrange + var userId = Guid.NewGuid(); + _sessionsRepositoryMock.Setup(r => r.GetByUserIdAsync(userId)) + .ThrowsAsync(new NotFoundByKeyException(userId)); + + // Act & Assert + var ex = Assert.ThrowsAsync(() => + _revoker.CloseAllSessionsAsync(userId)); + Assert.That(ex.Message, Is.EqualTo("Session not found")); + _loggerMock.Verify(l => l.Log( + LogLevel.Warning, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>()), Times.Once()); + _sessionsRepositoryMock.Verify(r => r.UpdateAsync(It.IsAny()), Times.Never()); + } +} \ No newline at end of file diff --git a/Govor.Core/Models/GroupMembership.cs b/Govor.Core/Models/GroupMembership.cs index 866cc59..509b19e 100644 --- a/Govor.Core/Models/GroupMembership.cs +++ b/Govor.Core/Models/GroupMembership.cs @@ -7,4 +7,5 @@ public class GroupMembership public Guid UserId { get; set; } public Guid? InvitationId { get; set; } public bool IsBanned { get; set; } + public DateTime MemberSince { get; set; } } \ No newline at end of file From e2177166047bb98fb57fd6820f9832825c27f1c4 Mon Sep 17 00:00:00 2001 From: stalcke2288969 Date: Thu, 24 Jul 2025 11:39:54 +0000 Subject: [PATCH 073/101] GITBOOK-7: No subject --- README.md => docs/README.md | 0 docs/SUMMARY.md | 29 +++++++ docs/endpoints/sessioncontroller.md | 126 ++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+) rename README.md => docs/README.md (100%) create mode 100644 docs/SUMMARY.md create mode 100644 docs/endpoints/sessioncontroller.md diff --git a/README.md b/docs/README.md similarity index 100% rename from README.md rename to docs/README.md diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md new file mode 100644 index 0000000..145002e --- /dev/null +++ b/docs/SUMMARY.md @@ -0,0 +1,29 @@ +# Table of contents + +* [Page](README.md) + +## Endpoints + +* [Authentication](../endpoints/authentication/README.md) + * [AuthController](../endpoints/authentication/authcontroller.md) + * [RefreshController](../endpoints/authentication/refreshcontroller.md) +* [SessionController](endpoints/sessioncontroller.md) +* [FriendshipController](../endpoints/friendshipcontroller.md) +* [FriendsRequestQueryController](../endpoints/friendsrequestquerycontroller.md) +* [MediaController](../endpoints/mediacontroller.md) +* [ChatLoadController](../endpoints/chatloadcontroller.md) +* [OnlinePingingController(не работает)](../endpoints/onlinepingingcontroller-ne-rabotaet.md) + +## SignalR + +* [PresenceHub](../signalr/presencehub.md) +* [ChatHub](../signalr/chathub.md) + +*** + +* [FriendsHub](../friendshub/README.md) + * [FriendsHub Client (Java)](../friendshub/friendshub-client-java.md) + +## Code Docs + +* [HubResult\](../code-docs/hubresult-less-than-t-greater-than.md) diff --git a/docs/endpoints/sessioncontroller.md b/docs/endpoints/sessioncontroller.md new file mode 100644 index 0000000..afb2b05 --- /dev/null +++ b/docs/endpoints/sessioncontroller.md @@ -0,0 +1,126 @@ +--- +description: >- + Controller for managing user sessions, including retrieving and closing + sessions. +--- + +# SessionController + +### Controller Description + +* **Route**: `api/session` +* **Authorize**: Requires authenticated user with roles "Admin" or "User" (`[Authorize(Roles = "Admin,User")]`). + +### Endpoints + +#### GetAllSessions + +* **Description**: Retrieves all active sessions for the authenticated user. +* **Route**: `[GET] api/session/all` +* **HTTP Method**: `GET` +* **Request**: None +* **Responses**: + * **200 OK**: Returns a list of user sessions. + + ```json + [ + { + "id": "Guid", + "deviceInfo": "string", + "createdAt": "DateTime", + "expiresAt": "DateTime", + "isRevoked": "boolean" + } + ] + ``` + * **403 Forbidden**: If user lacks authorization. + + ```json + "string" + ``` + * **500 Internal Server Error**: Indicates an unexpected error. + + ```json + "Unexpected Error! Please try again later." + ``` + +#### CloseSession + +* **Description**: Closes a specific session for the authenticated user. +* **Route**: `[DELETE] api/session/close/{sessionId}` +* **HTTP Method**: `DELETE` +* **Request**: + * **Path Parameter**: `sessionId` (Guid, required): ID of the session to close. +* **Responses**: + * **200 OK**: Indicates session was successfully closed. + * No body. + * **400 Bad Request**: If `sessionId` is invalid or operation fails. + + ```json + "string" + ``` + * **403 Forbidden**: If user lacks authorization. + + ```json + "string" + ``` + * **404 Not Found**: If session is not found. + + ```json + "string" + ``` + * **500 Internal Server Error**: Indicates an unexpected error. + + ```json + "Unexpected Error! Please try again later." + ``` + +#### CloseAllSessions + +* **Description**: Closes all active sessions for the authenticated user. +* **Route**: `[DELETE] api/session/close/all` +* **HTTP Method**: `DELETE` +* **Request**: None +* **Responses**: + * **200 OK**: Indicates all sessions were successfully closed. + * No body. + * **403 Forbidden**: If user lacks authorization. + + ```json + "string" + ``` + * **500 Internal Server Error**: Indicates an unexpected error. + + ```json + "Unexpected Error! Please try again later." + ``` + +### Data Models + +#### SessionDto + +* **Description**: Data transfer object for session information. +* **Structure**: + + ```json + { + "id": "Guid", + "deviceInfo": "string", + "createdAt": "DateTime", + "expiresAt": "DateTime", + "isRevoked": "boolean" + } + ``` + +### Error Handling + +* **Invalid User ID**: Handled by `ICurrentUserService`, aborts if invalid. +* **Invalid Operations**: Returns `400 Bad Request` for `InvalidOperationException`. +* **Unauthorized Access**: Returns `403 Forbidden` for `UnauthorizedAccessException`. +* **Resource Not Found**: Returns `404 Not Found` for `NotFoundException`. +* **Unexpected Errors**: Caught and returned as `500 Internal Server Error`. + +### Logging + +* Logs warnings for `UnauthorizedAccessException` and `NotFoundException`. +* Logs errors for `InvalidOperationException` and unexpected exceptions. From ad4f052253cfb0ca94063ebfce907eac35274b35 Mon Sep 17 00:00:00 2001 From: stalcke2288969 Date: Thu, 24 Jul 2025 11:41:13 +0000 Subject: [PATCH 074/101] GITBOOK-8: No subject From a5614f76867ddc6de14b1c20aed574b2193f561e Mon Sep 17 00:00:00 2001 From: stalcke2288969 Date: Thu, 24 Jul 2025 13:34:28 +0000 Subject: [PATCH 075/101] GITBOOK-9: No subject --- endpoints/friendshipcontroller.md | 4 ++-- endpoints/friendsrequestquerycontroller.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/endpoints/friendshipcontroller.md b/endpoints/friendshipcontroller.md index 4ba6894..bb878cf 100644 --- a/endpoints/friendshipcontroller.md +++ b/endpoints/friendshipcontroller.md @@ -1,7 +1,7 @@ --- description: >- - Description: Controller for managing friendship-related operations, including - searching for users and retrieving the list of friends for the current user. + Controller for managing friendship-related operations, including searching for + users and retrieving the list of friends for the current user. --- # FriendshipController diff --git a/endpoints/friendsrequestquerycontroller.md b/endpoints/friendsrequestquerycontroller.md index 0af2473..8db54ed 100644 --- a/endpoints/friendsrequestquerycontroller.md +++ b/endpoints/friendsrequestquerycontroller.md @@ -1,7 +1,7 @@ --- description: >- - Description: Controller for querying friend request-related data, including - incoming friend requests and responses to sent friend requests. + Controller for querying friend request-related data, including incoming friend + requests and responses to sent friend requests. --- # FriendsRequestQueryController From 1fe6e015fc8784ab280cd30561fe6550d58ff263 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Thu, 24 Jul 2025 20:35:50 +0700 Subject: [PATCH 076/101] rework docs --- .../hubresult-less-than-t-greater-than.md | 0 .../endpoints}/authentication/README.md | 0 .../authentication/authcontroller.md | 0 .../authentication/refreshcontroller.md | 0 .../endpoints}/chatloadcontroller.md | 0 docs/endpoints/friendshipcontroller.md | 114 ++++++++++++++++++ .../friendsrequestquerycontroller.md | 109 +++++++++++++++++ .../endpoints}/mediacontroller.md | 0 .../onlinepingingcontroller-ne-rabotaet.md | 0 {friendshub => docs/friendshub}/README.md | 0 .../friendshub}/friendshub-client-java.md | 0 {signalr => docs/signalr}/chathub.md | 0 {signalr => docs/signalr}/presencehub.md | 0 13 files changed, 223 insertions(+) rename {code-docs => docs/code-docs}/hubresult-less-than-t-greater-than.md (100%) rename {endpoints => docs/endpoints}/authentication/README.md (100%) rename {endpoints => docs/endpoints}/authentication/authcontroller.md (100%) rename {endpoints => docs/endpoints}/authentication/refreshcontroller.md (100%) rename {endpoints => docs/endpoints}/chatloadcontroller.md (100%) create mode 100644 docs/endpoints/friendshipcontroller.md create mode 100644 docs/endpoints/friendsrequestquerycontroller.md rename {endpoints => docs/endpoints}/mediacontroller.md (100%) rename {endpoints => docs/endpoints}/onlinepingingcontroller-ne-rabotaet.md (100%) rename {friendshub => docs/friendshub}/README.md (100%) rename {friendshub => docs/friendshub}/friendshub-client-java.md (100%) rename {signalr => docs/signalr}/chathub.md (100%) rename {signalr => docs/signalr}/presencehub.md (100%) diff --git a/code-docs/hubresult-less-than-t-greater-than.md b/docs/code-docs/hubresult-less-than-t-greater-than.md similarity index 100% rename from code-docs/hubresult-less-than-t-greater-than.md rename to docs/code-docs/hubresult-less-than-t-greater-than.md diff --git a/endpoints/authentication/README.md b/docs/endpoints/authentication/README.md similarity index 100% rename from endpoints/authentication/README.md rename to docs/endpoints/authentication/README.md diff --git a/endpoints/authentication/authcontroller.md b/docs/endpoints/authentication/authcontroller.md similarity index 100% rename from endpoints/authentication/authcontroller.md rename to docs/endpoints/authentication/authcontroller.md diff --git a/endpoints/authentication/refreshcontroller.md b/docs/endpoints/authentication/refreshcontroller.md similarity index 100% rename from endpoints/authentication/refreshcontroller.md rename to docs/endpoints/authentication/refreshcontroller.md diff --git a/endpoints/chatloadcontroller.md b/docs/endpoints/chatloadcontroller.md similarity index 100% rename from endpoints/chatloadcontroller.md rename to docs/endpoints/chatloadcontroller.md diff --git a/docs/endpoints/friendshipcontroller.md b/docs/endpoints/friendshipcontroller.md new file mode 100644 index 0000000..4ba6894 --- /dev/null +++ b/docs/endpoints/friendshipcontroller.md @@ -0,0 +1,114 @@ +--- +description: >- + Description: Controller for managing friendship-related operations, including + searching for users and retrieving the list of friends for the current user. +--- + +# FriendshipController + +### Controller Description + +* **Route**: `api/friends` +* **Authorize**: Requires authenticated user (`[Authorize]`). + +### Endpoints + +#### Search + +* **Description**: Searches for users based on a query string. +* **Route**: `[GET] api/friends/search?query=` +* **HTTP Method**: `GET` +* **Request**: + * **Query Parameter**: `query` (string, required) +* **Responses**: + * **200 OK**: Returns a list of matching users. + + ```json + [ + { + "id": "Guid", + "username": "string", + "description": "string", + "wasOnline": "DateTime", + "iconId": "Guid" + } + ] + ``` + * **400 Bad Request**: If the query is empty. + + ```json + "Query cannot be empty" + ``` + * **403 Forbidden**: If user lacks authorization. + + ```json + "string" + ``` + * **500 Internal Server Error**: Indicates an unexpected error. + + ```json + { + "error": "Internal error during user search." + } + ``` + +#### GetFriends + +* **Description**: Retrieves the list of friends for the authenticated user. +* **Route**: `[GET] api/friends` +* **HTTP Method**: `GET` +* **Request**: None +* **Responses**: + * **200 OK**: Returns a list of the user's friends or an empty list if none. + + ```json + [ + { + "id": "Guid", + "username": "string", + "description": "string", + "wasOnline": "DateTime", + "iconId": "Guid" + } + ] + ``` + * **403 Forbidden**: If user lacks authorization. + + ```json + "string" + ``` + * **500 Internal Server Error**: Indicates an unexpected error. + + ```json + { + "error": "Internal server error." + } + ``` + +### Data Models + +#### UserDto + +* **Description**: Data transfer object for user information. +* **Structure**: + + ```json + { + "id": "Guid", + "username": "string", + "description": "string", + "wasOnline": "DateTime", + "iconId": "Guid" + } + ``` + +### Error Handling + +* **Invalid User ID**: Handled by `ICurrentUserService`, aborts if invalid. +* **Invalid Operations**: Returns `400 Bad Request` for empty query. +* **Unexpected Errors**: Caught and returned as `500 Internal Server Error`. + +### Logging + +* Logs warnings for `SearchUsersException`. +* Logs errors for unexpected exceptions and `InvalidOperationException`. diff --git a/docs/endpoints/friendsrequestquerycontroller.md b/docs/endpoints/friendsrequestquerycontroller.md new file mode 100644 index 0000000..0af2473 --- /dev/null +++ b/docs/endpoints/friendsrequestquerycontroller.md @@ -0,0 +1,109 @@ +--- +description: >- + Description: Controller for querying friend request-related data, including + incoming friend requests and responses to sent friend requests. +--- + +# FriendsRequestQueryController + +### Controller Description + +* **Route**: `api/friends` +* **Authorize**: Requires authenticated user (`[Authorize]`). + +### Endpoints + +#### GetIncomingRequests + +* **Description**: Retrieves a list of incoming friend requests for the authenticated user. +* **Route**: `[GET] api/friends/requests` +* **HTTP Method**: `GET` +* **Request**: None +* **Responses**: + * **200 OK**: Returns a list of incoming friend requests or an empty list if none. + + ```json + [ + { + "id": "Guid", + "senderId": "Guid", + "receiverId": "Guid", + "status": "string", + "createdAt": "DateTime" + } + ] + ``` + * **403 Forbidden**: If user lacks authorization. + + ```json + "string" + ``` + * **500 Internal Server Error**: Indicates an unexpected error. + + ```json + { + "error": "Internal server error." + } + ``` + +#### GetResponses + +* **Description**: Retrieves a list of responses to the authenticated user's sent friend requests. +* **Route**: `[GET] api/friends/responses` +* **HTTP Method**: `GET` +* **Request**: None +* **Responses**: + * **200 OK**: Returns a list of friend request responses or an empty list if none. + + ```json + [ + { + "id": "Guid", + "senderId": "Guid", + "receiverId": "Guid", + "status": "string", + "createdAt": "DateTime" + } + ] + ``` + * **403 Forbidden**: If user lacks authorization. + + ```json + "string" + ``` + * **500 Internal Server Error**: Indicates an unexpected error. + + ```json + { + "error": "Internal server error." + } + ``` + +### Data Models + +#### FriendshipDto + +* **Description**: Data transfer object for friend request information. +* **Structure**: + + ```json + { + "id": "Guid", + "senderId": "Guid", + "receiverId": "Guid", + "status": "string", + "createdAt": "DateTime" + } + ``` + +### Error Handling + +* **Invalid User ID**: Handled by `ICurrentUserService`, aborts if invalid. +* **Invalid Operations**: Returns `200 OK` with an empty list for `InvalidOperationException`. +* **Unexpected Errors**: Caught and returned as `500 Internal Server Error`. + +### Logging + +* Logs warnings for `InvalidOperationException`. +* Logs information for successful response retrieval. +* Logs errors for unexpected exceptions. diff --git a/endpoints/mediacontroller.md b/docs/endpoints/mediacontroller.md similarity index 100% rename from endpoints/mediacontroller.md rename to docs/endpoints/mediacontroller.md diff --git a/endpoints/onlinepingingcontroller-ne-rabotaet.md b/docs/endpoints/onlinepingingcontroller-ne-rabotaet.md similarity index 100% rename from endpoints/onlinepingingcontroller-ne-rabotaet.md rename to docs/endpoints/onlinepingingcontroller-ne-rabotaet.md diff --git a/friendshub/README.md b/docs/friendshub/README.md similarity index 100% rename from friendshub/README.md rename to docs/friendshub/README.md diff --git a/friendshub/friendshub-client-java.md b/docs/friendshub/friendshub-client-java.md similarity index 100% rename from friendshub/friendshub-client-java.md rename to docs/friendshub/friendshub-client-java.md diff --git a/signalr/chathub.md b/docs/signalr/chathub.md similarity index 100% rename from signalr/chathub.md rename to docs/signalr/chathub.md diff --git a/signalr/presencehub.md b/docs/signalr/presencehub.md similarity index 100% rename from signalr/presencehub.md rename to docs/signalr/presencehub.md From 7ac5565aeac7a4958e3f02002d43510b9d71f23e Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Thu, 24 Jul 2025 20:36:45 +0700 Subject: [PATCH 077/101] re --- endpoints/friendshipcontroller.md | 114 --------------------- endpoints/friendsrequestquerycontroller.md | 109 -------------------- 2 files changed, 223 deletions(-) delete mode 100644 endpoints/friendshipcontroller.md delete mode 100644 endpoints/friendsrequestquerycontroller.md diff --git a/endpoints/friendshipcontroller.md b/endpoints/friendshipcontroller.md deleted file mode 100644 index bb878cf..0000000 --- a/endpoints/friendshipcontroller.md +++ /dev/null @@ -1,114 +0,0 @@ ---- -description: >- - Controller for managing friendship-related operations, including searching for - users and retrieving the list of friends for the current user. ---- - -# FriendshipController - -### Controller Description - -* **Route**: `api/friends` -* **Authorize**: Requires authenticated user (`[Authorize]`). - -### Endpoints - -#### Search - -* **Description**: Searches for users based on a query string. -* **Route**: `[GET] api/friends/search?query=` -* **HTTP Method**: `GET` -* **Request**: - * **Query Parameter**: `query` (string, required) -* **Responses**: - * **200 OK**: Returns a list of matching users. - - ```json - [ - { - "id": "Guid", - "username": "string", - "description": "string", - "wasOnline": "DateTime", - "iconId": "Guid" - } - ] - ``` - * **400 Bad Request**: If the query is empty. - - ```json - "Query cannot be empty" - ``` - * **403 Forbidden**: If user lacks authorization. - - ```json - "string" - ``` - * **500 Internal Server Error**: Indicates an unexpected error. - - ```json - { - "error": "Internal error during user search." - } - ``` - -#### GetFriends - -* **Description**: Retrieves the list of friends for the authenticated user. -* **Route**: `[GET] api/friends` -* **HTTP Method**: `GET` -* **Request**: None -* **Responses**: - * **200 OK**: Returns a list of the user's friends or an empty list if none. - - ```json - [ - { - "id": "Guid", - "username": "string", - "description": "string", - "wasOnline": "DateTime", - "iconId": "Guid" - } - ] - ``` - * **403 Forbidden**: If user lacks authorization. - - ```json - "string" - ``` - * **500 Internal Server Error**: Indicates an unexpected error. - - ```json - { - "error": "Internal server error." - } - ``` - -### Data Models - -#### UserDto - -* **Description**: Data transfer object for user information. -* **Structure**: - - ```json - { - "id": "Guid", - "username": "string", - "description": "string", - "wasOnline": "DateTime", - "iconId": "Guid" - } - ``` - -### Error Handling - -* **Invalid User ID**: Handled by `ICurrentUserService`, aborts if invalid. -* **Invalid Operations**: Returns `400 Bad Request` for empty query. -* **Unexpected Errors**: Caught and returned as `500 Internal Server Error`. - -### Logging - -* Logs warnings for `SearchUsersException`. -* Logs errors for unexpected exceptions and `InvalidOperationException`. diff --git a/endpoints/friendsrequestquerycontroller.md b/endpoints/friendsrequestquerycontroller.md deleted file mode 100644 index 8db54ed..0000000 --- a/endpoints/friendsrequestquerycontroller.md +++ /dev/null @@ -1,109 +0,0 @@ ---- -description: >- - Controller for querying friend request-related data, including incoming friend - requests and responses to sent friend requests. ---- - -# FriendsRequestQueryController - -### Controller Description - -* **Route**: `api/friends` -* **Authorize**: Requires authenticated user (`[Authorize]`). - -### Endpoints - -#### GetIncomingRequests - -* **Description**: Retrieves a list of incoming friend requests for the authenticated user. -* **Route**: `[GET] api/friends/requests` -* **HTTP Method**: `GET` -* **Request**: None -* **Responses**: - * **200 OK**: Returns a list of incoming friend requests or an empty list if none. - - ```json - [ - { - "id": "Guid", - "senderId": "Guid", - "receiverId": "Guid", - "status": "string", - "createdAt": "DateTime" - } - ] - ``` - * **403 Forbidden**: If user lacks authorization. - - ```json - "string" - ``` - * **500 Internal Server Error**: Indicates an unexpected error. - - ```json - { - "error": "Internal server error." - } - ``` - -#### GetResponses - -* **Description**: Retrieves a list of responses to the authenticated user's sent friend requests. -* **Route**: `[GET] api/friends/responses` -* **HTTP Method**: `GET` -* **Request**: None -* **Responses**: - * **200 OK**: Returns a list of friend request responses or an empty list if none. - - ```json - [ - { - "id": "Guid", - "senderId": "Guid", - "receiverId": "Guid", - "status": "string", - "createdAt": "DateTime" - } - ] - ``` - * **403 Forbidden**: If user lacks authorization. - - ```json - "string" - ``` - * **500 Internal Server Error**: Indicates an unexpected error. - - ```json - { - "error": "Internal server error." - } - ``` - -### Data Models - -#### FriendshipDto - -* **Description**: Data transfer object for friend request information. -* **Structure**: - - ```json - { - "id": "Guid", - "senderId": "Guid", - "receiverId": "Guid", - "status": "string", - "createdAt": "DateTime" - } - ``` - -### Error Handling - -* **Invalid User ID**: Handled by `ICurrentUserService`, aborts if invalid. -* **Invalid Operations**: Returns `200 OK` with an empty list for `InvalidOperationException`. -* **Unexpected Errors**: Caught and returned as `500 Internal Server Error`. - -### Logging - -* Logs warnings for `InvalidOperationException`. -* Logs information for successful response retrieval. -* Logs errors for unexpected exceptions. From 5a734ecbdcc3ae55ae837d6244b2effb68750345 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Thu, 24 Jul 2025 20:37:20 +0700 Subject: [PATCH 078/101] Delete SUMMARY.md --- SUMMARY.md | 28 ---------------------------- 1 file changed, 28 deletions(-) delete mode 100644 SUMMARY.md diff --git a/SUMMARY.md b/SUMMARY.md deleted file mode 100644 index 3412688..0000000 --- a/SUMMARY.md +++ /dev/null @@ -1,28 +0,0 @@ -# Table of contents - -* [Page](README.md) - -## Endpoints - -* [Authentication](endpoints/authentication/README.md) - * [AuthController](endpoints/authentication/authcontroller.md) - * [RefreshController](endpoints/authentication/refreshcontroller.md) -* [FriendshipController](endpoints/friendshipcontroller.md) -* [FriendsRequestQueryController](endpoints/friendsrequestquerycontroller.md) -* [MediaController](endpoints/mediacontroller.md) -* [ChatLoadController](endpoints/chatloadcontroller.md) -* [OnlinePingingController(не работает)](endpoints/onlinepingingcontroller-ne-rabotaet.md) - -## SignalR - -* [PresenceHub](signalr/presencehub.md) -* [ChatHub](signalr/chathub.md) - -*** - -* [FriendsHub](friendshub/README.md) - * [FriendsHub Client (Java)](friendshub/friendshub-client-java.md) - -## Code Docs - -* [HubResult\](code-docs/hubresult-less-than-t-greater-than.md) From e9eee5bfecec933ab4778ae02433bfa3feb9c64c Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Thu, 24 Jul 2025 21:37:12 +0700 Subject: [PATCH 079/101] UserSession namespace was changed --- .../Controllers/SessionControllerTests.cs | 1 + Govor.API/Hubs/GroupsHub.cs | 8 ++ .../UserSessions/UserSessionReaderTests.cs | 1 + .../UserSessions/UserSessionRevokerTests.cs | 1 + .../UserSession/IUserSessionReader.cs | 2 +- .../UserSessions/UserSessionOpener.cs | 129 +++++++++--------- .../UserSessions/UserSessionReader.cs | 3 +- .../UserSessions/UserSessionRefresher.cs | 2 +- Govor.Core/Models/{ => Users}/UserSession.cs | 2 +- .../IUserSessionsExist.cs | 2 +- .../IUserSessionsReader.cs | 2 +- .../IUserSessionsWriter.cs | 1 + .../UserSessionsRepositoryTests.cs | 1 + .../Repositories/UserSessionsRepository.cs | 2 +- 14 files changed, 85 insertions(+), 72 deletions(-) create mode 100644 Govor.API/Hubs/GroupsHub.cs rename Govor.Core/Models/{ => Users}/UserSession.cs (96%) diff --git a/Govor.API.Tests/Controllers/SessionControllerTests.cs b/Govor.API.Tests/Controllers/SessionControllerTests.cs index 3b95be3..b8add34 100644 --- a/Govor.API.Tests/Controllers/SessionControllerTests.cs +++ b/Govor.API.Tests/Controllers/SessionControllerTests.cs @@ -4,6 +4,7 @@ using Govor.Application.Interfaces.Infrastructure.Extensions; using Govor.Application.Interfaces.UserSession; using Govor.Contracts.DTOs; using Govor.Core.Models; +using Govor.Core.Models.Users; using Govor.Data.Repositories.Exceptions; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; diff --git a/Govor.API/Hubs/GroupsHub.cs b/Govor.API/Hubs/GroupsHub.cs new file mode 100644 index 0000000..2469e1e --- /dev/null +++ b/Govor.API/Hubs/GroupsHub.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.SignalR; + +namespace Govor.API.Hubs; + +public class GroupsHub : Hub +{ + +} \ No newline at end of file diff --git a/Govor.Application.Tests/Services/UserSessions/UserSessionReaderTests.cs b/Govor.Application.Tests/Services/UserSessions/UserSessionReaderTests.cs index 1b8e698..fbbcf6f 100644 --- a/Govor.Application.Tests/Services/UserSessions/UserSessionReaderTests.cs +++ b/Govor.Application.Tests/Services/UserSessions/UserSessionReaderTests.cs @@ -2,6 +2,7 @@ using AutoFixture; using Govor.Application.Interfaces.UserSession; using Govor.Application.Services.UserSessions; using Govor.Core.Models; +using Govor.Core.Models.Users; using Govor.Core.Repositories.UserSessionsRepository; using Govor.Data.Repositories.Exceptions; using Microsoft.Extensions.Logging; diff --git a/Govor.Application.Tests/Services/UserSessions/UserSessionRevokerTests.cs b/Govor.Application.Tests/Services/UserSessions/UserSessionRevokerTests.cs index 26066ee..cd40008 100644 --- a/Govor.Application.Tests/Services/UserSessions/UserSessionRevokerTests.cs +++ b/Govor.Application.Tests/Services/UserSessions/UserSessionRevokerTests.cs @@ -1,5 +1,6 @@ using Govor.Application.Services.UserSessions; using Govor.Core.Models; +using Govor.Core.Models.Users; using Govor.Core.Repositories.UserSessionsRepository; using Govor.Data.Repositories.Exceptions; using Microsoft.Extensions.Logging; diff --git a/Govor.Application/Interfaces/UserSession/IUserSessionReader.cs b/Govor.Application/Interfaces/UserSession/IUserSessionReader.cs index 3824747..3800917 100644 --- a/Govor.Application/Interfaces/UserSession/IUserSessionReader.cs +++ b/Govor.Application/Interfaces/UserSession/IUserSessionReader.cs @@ -2,5 +2,5 @@ namespace Govor.Application.Interfaces.UserSession; public interface IUserSessionReader { - Task> GetAllSessionsAsync(Guid userId); + Task> GetAllSessionsAsync(Guid userId); } diff --git a/Govor.Application/Services/UserSessions/UserSessionOpener.cs b/Govor.Application/Services/UserSessions/UserSessionOpener.cs index 5fe5e59..2d3d2be 100644 --- a/Govor.Application/Services/UserSessions/UserSessionOpener.cs +++ b/Govor.Application/Services/UserSessions/UserSessionOpener.cs @@ -7,83 +7,84 @@ using Govor.Data.Repositories.Exceptions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -namespace Govor.Application.Services.UserSessions; - -public class UserSessionOpener : IUserSessionOpener +namespace Govor.Application.Services.UserSessions { - private readonly IUserSessionsRepository _repository; - private readonly ILogger _logger; - private readonly JwtRefreshOption _options; - private readonly IJwtService _jwtService; - - public UserSessionOpener( - IUserSessionsRepository repository, - IJwtService jwtService, - IOptions options, - ILogger logger) + public class UserSessionOpener : IUserSessionOpener { - _jwtService = jwtService; - _repository = repository; - _logger = logger; - _options = options.Value; - } + private readonly IUserSessionsRepository _repository; + private readonly ILogger _logger; + private readonly JwtRefreshOption _options; + private readonly IJwtService _jwtService; - public async Task OpenSessionAsync(User user, string deviceInfo) - { - _logger.LogInformation($"Opening session for user {user.Id} on device '{deviceInfo}'"); - - try + public UserSessionOpener( + IUserSessionsRepository repository, + IJwtService jwtService, + IOptions options, + ILogger logger) { - var sessions = await _repository.GetByUserIdAsync(user.Id); - var session = sessions.FirstOrDefault(s => s.DeviceInfo == deviceInfo); + _jwtService = jwtService; + _repository = repository; + _logger = logger; + _options = options.Value; + } - var newRefreshToken = await _jwtService.GenerateRefreshTokenAsync(user); - var accessToken = await _jwtService.GenerateAccessTokenAsync(user); - - var newExpiresAt = DateTime.UtcNow.AddDays(_options.RefreshTokenLifetimeDays); + public async Task OpenSessionAsync(User user, string deviceInfo) + { + _logger.LogInformation($"Opening session for user {user.Id} on device '{deviceInfo}'"); - if (session is not null) + try { - // Всегда обновляем токен и дату - session.RefreshToken = newRefreshToken; - session.ExpiresAt = newExpiresAt; - session.CreatedAt = DateTime.UtcNow; - session.IsRevoked = false; + var sessions = await _repository.GetByUserIdAsync(user.Id); + var session = sessions.FirstOrDefault(s => s.DeviceInfo == deviceInfo); - await _repository.UpdateAsync(session); - _logger.LogInformation($"Updated session for user {user.Id} on device '{deviceInfo}'"); + var newRefreshToken = await _jwtService.GenerateRefreshTokenAsync(user); + var accessToken = await _jwtService.GenerateAccessTokenAsync(user); + + var newExpiresAt = DateTime.UtcNow.AddDays(_options.RefreshTokenLifetimeDays); - return new RefreshResult(session.RefreshToken, accessToken); + if (session is not null) + { + // Всегда обновляем токен и дату + session.RefreshToken = newRefreshToken; + session.ExpiresAt = newExpiresAt; + session.CreatedAt = DateTime.UtcNow; + session.IsRevoked = false; + + await _repository.UpdateAsync(session); + _logger.LogInformation($"Updated session for user {user.Id} on device '{deviceInfo}'"); + + return new RefreshResult(session.RefreshToken, accessToken); + } + + return await OpenNewSession(); } - - return await OpenNewSession(); - } - catch (NotFoundByKeyException ex) - { - return await OpenNewSession(); - } - - async Task OpenNewSession() - { - var newRefreshToken = await _jwtService.GenerateRefreshTokenAsync(user); - var accessToken = await _jwtService.GenerateAccessTokenAsync(user); - - var newSession = new Core.Models.UserSession + catch (NotFoundByKeyException ex) { - UserId = user.Id, - DeviceInfo = deviceInfo, - RefreshToken = newRefreshToken, - CreatedAt = DateTime.UtcNow, - ExpiresAt = DateTime.UtcNow.AddDays(_options.RefreshTokenLifetimeDays), - IsRevoked = false - }; - - await _repository.AddAsync(newSession); + return await OpenNewSession(); + } + + async Task OpenNewSession() + { + var newRefreshToken = await _jwtService.GenerateRefreshTokenAsync(user); + var accessToken = await _jwtService.GenerateAccessTokenAsync(user); - _logger.LogInformation($"Created new session for user {user.Id} on device '{deviceInfo}'"); + var newSession = new UserSession + { + UserId = user.Id, + DeviceInfo = deviceInfo, + RefreshToken = newRefreshToken, + CreatedAt = DateTime.UtcNow, + ExpiresAt = DateTime.UtcNow.AddDays(_options.RefreshTokenLifetimeDays), + IsRevoked = false + }; - return new RefreshResult(newRefreshToken, accessToken); + await _repository.AddAsync(newSession); + + _logger.LogInformation($"Created new session for user {user.Id} on device '{deviceInfo}'"); + + return new RefreshResult(newRefreshToken, accessToken); + } } } } - \ No newline at end of file + diff --git a/Govor.Application/Services/UserSessions/UserSessionReader.cs b/Govor.Application/Services/UserSessions/UserSessionReader.cs index cd93052..f9ba04c 100644 --- a/Govor.Application/Services/UserSessions/UserSessionReader.cs +++ b/Govor.Application/Services/UserSessions/UserSessionReader.cs @@ -1,5 +1,4 @@ using Govor.Application.Interfaces.UserSession; -using Govor.Core.Models; using Govor.Core.Repositories.UserSessionsRepository; using Govor.Data.Repositories.Exceptions; using Microsoft.Extensions.Logging; @@ -17,7 +16,7 @@ public class UserSessionReader : IUserSessionReader _logger = logger; } - public async Task> GetAllSessionsAsync(Guid userId) + public async Task> GetAllSessionsAsync(Guid userId) { try { diff --git a/Govor.Application/Services/UserSessions/UserSessionRefresher.cs b/Govor.Application/Services/UserSessions/UserSessionRefresher.cs index 1404404..b6a968f 100644 --- a/Govor.Application/Services/UserSessions/UserSessionRefresher.cs +++ b/Govor.Application/Services/UserSessions/UserSessionRefresher.cs @@ -1,7 +1,7 @@ using Govor.Application.Interfaces.Authentication; using Govor.Application.Interfaces.UserSession; using Govor.Application.Services.Authentication; -using Govor.Core.Models; +using Govor.Core.Models.Users; using Govor.Core.Repositories.Users; using Govor.Core.Repositories.UserSessionsRepository; using Govor.Data.Repositories.Exceptions; diff --git a/Govor.Core/Models/UserSession.cs b/Govor.Core/Models/Users/UserSession.cs similarity index 96% rename from Govor.Core/Models/UserSession.cs rename to Govor.Core/Models/Users/UserSession.cs index ce6cdaa..62f8384 100644 --- a/Govor.Core/Models/UserSession.cs +++ b/Govor.Core/Models/Users/UserSession.cs @@ -1,4 +1,4 @@ -namespace Govor.Core.Models; +namespace Govor.Core.Models.Users; public class UserSession { diff --git a/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsExist.cs b/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsExist.cs index 38a16fd..9d4ff9e 100644 --- a/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsExist.cs +++ b/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsExist.cs @@ -1,4 +1,4 @@ -using Govor.Core.Models; +using Govor.Core.Models.Users; namespace Govor.Core.Repositories.UserSessionsRepository; diff --git a/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsReader.cs b/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsReader.cs index 6d4fc71..b86be83 100644 --- a/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsReader.cs +++ b/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsReader.cs @@ -1,4 +1,4 @@ -using Govor.Core.Models; +using Govor.Core.Models.Users; namespace Govor.Core.Repositories.UserSessionsRepository; diff --git a/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsWriter.cs b/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsWriter.cs index aa6eeaa..4f7eeac 100644 --- a/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsWriter.cs +++ b/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsWriter.cs @@ -1,4 +1,5 @@ using Govor.Core.Models; +using Govor.Core.Models.Users; namespace Govor.Core.Repositories.UserSessionsRepository; diff --git a/Govor.Data.Tests/Repositories/UserSessionsRepositoryTests.cs b/Govor.Data.Tests/Repositories/UserSessionsRepositoryTests.cs index 37dd421..6871a63 100644 --- a/Govor.Data.Tests/Repositories/UserSessionsRepositoryTests.cs +++ b/Govor.Data.Tests/Repositories/UserSessionsRepositoryTests.cs @@ -1,5 +1,6 @@ using AutoFixture; using Govor.Core.Models; +using Govor.Core.Models.Users; using Govor.Data.Repositories; using Govor.Data.Repositories.Exceptions; using Microsoft.EntityFrameworkCore; diff --git a/Govor.Data/Repositories/UserSessionsRepository.cs b/Govor.Data/Repositories/UserSessionsRepository.cs index c4ed8d1..37cb6eb 100644 --- a/Govor.Data/Repositories/UserSessionsRepository.cs +++ b/Govor.Data/Repositories/UserSessionsRepository.cs @@ -1,5 +1,5 @@ using Govor.Core.Infrastructure.Extensions; -using Govor.Core.Models; +using Govor.Core.Models.Users; using Govor.Core.Repositories.UserSessionsRepository; using Govor.Data.Repositories.Exceptions; using Microsoft.EntityFrameworkCore; From ce630fbe0abd4e2ff86f5651c92cf34ec8e3e602 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Fri, 25 Jul 2025 17:54:58 +0700 Subject: [PATCH 080/101] Add sessionId to JWT and update session handling The JwtService now includes sessionId in access tokens, and all usages have been updated to pass sessionId when generating tokens. UserSession now supports public encryption and signing keys. Tests and session management logic have been updated to reflect these changes, ensuring session-specific claims in JWTs and improved session tracking. --- .../Authentication/SessionKeysController.cs | 17 +++++++ .../Authentication/JwtServiceTests.cs | 47 +++++++++++++++---- .../UserSessions/UserSessionOpenerTests.cs | 17 ++++--- .../UserSessions/UserSessionRefresherTests.cs | 2 +- .../Interfaces/Authentication/IJwtService.cs | 2 +- .../Services/Authentication/JwtService.cs | 3 +- .../UserSessions/UserSessionOpener.cs | 32 +++++++------ .../UserSessions/UserSessionRefresher.cs | 2 +- Govor.Core/Models/Users/UserSession.cs | 2 + 9 files changed, 90 insertions(+), 34 deletions(-) create mode 100644 Govor.API/Controllers/Authentication/SessionKeysController.cs diff --git a/Govor.API/Controllers/Authentication/SessionKeysController.cs b/Govor.API/Controllers/Authentication/SessionKeysController.cs new file mode 100644 index 0000000..b001c70 --- /dev/null +++ b/Govor.API/Controllers/Authentication/SessionKeysController.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Govor.API.Controllers.Authentication; + +[RequireHttps] +[ApiController] +[Route("api/session")] +[Authorize(Roles = "Admin, User")] +public class SessionKeysController : Controller +{ + // GET + public IActionResult Index() + { + return View(); + } +} \ No newline at end of file diff --git a/Govor.Application.Tests/Services/Authentication/JwtServiceTests.cs b/Govor.Application.Tests/Services/Authentication/JwtServiceTests.cs index 62953df..777e876 100644 --- a/Govor.Application.Tests/Services/Authentication/JwtServiceTests.cs +++ b/Govor.Application.Tests/Services/Authentication/JwtServiceTests.cs @@ -56,22 +56,51 @@ public class JwtServiceTests } [Test] - public async Task GenerateJwtToken_ShouldReturnValidJwtString() + public async Task GenerateJwtToken_ShouldReturnValidJwtString_WithCorrectClaims() { // Arrange var user = _fixture.Create(); + var sessionId = Guid.NewGuid(); var expectedRole = "User"; - _invitesServiceMock.Setup(s => s.GetRoleAsync(user)).Returns(Task.FromResult(expectedRole)); - // Act - var tokenString = await _jwtService.GenerateAccessTokenAsync(user); + _invitesServiceMock.Setup(s => s.GetRoleAsync(user)).ReturnsAsync(expectedRole); + + // Act + var tokenString = await _jwtService.GenerateAccessTokenAsync(user, sessionId); + + // Assert + var handler = new JwtSecurityTokenHandler(); + var jwt = handler.ReadJwtToken(tokenString); + + var claims = jwt.Claims.ToDictionary(c => c.Type, c => c.Value); + + Assert.That(claims["userId"], Is.EqualTo(user.Id.ToString())); + Assert.That(claims["sid"], Is.EqualTo(sessionId.ToString())); + Assert.That(claims[ClaimTypes.Role], Is.EqualTo(expectedRole)); + Assert.That(jwt.ValidTo, Is.GreaterThan(DateTime.UtcNow)); + } + + [Test] + public async Task GenerateAccessTokenAsync_ShouldIncludeSessionIdAndRole() + { + // Arrange + var user = new User { Id = Guid.NewGuid(), Username = "TestUser" }; + var sessionId = Guid.NewGuid(); + var role = "Admin"; + _invitesServiceMock.Setup(s => s.GetRoleAsync(user)).ReturnsAsync(role); + + // Act + var token = await _jwtService.GenerateAccessTokenAsync(user, sessionId); + var handler = new JwtSecurityTokenHandler(); + var jwt = handler.ReadJwtToken(token); // Assert - Assert.That(tokenString, Is.Not.Null.And.Not.Empty); - - // Attempt to parse the token to ensure it's a JWT - var handler = new JwtSecurityTokenHandler(); - Assert.DoesNotThrow(() => handler.ReadJwtToken(tokenString)); + var sidClaim = jwt.Claims.FirstOrDefault(c => c.Type == "sid"); + var roleClaim = jwt.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Role); + + Assert.That(sidClaim?.Value, Is.EqualTo(sessionId.ToString())); + Assert.That(roleClaim?.Value, Is.EqualTo(role)); } + [Test] public async Task GenerateRefreshTokenAsync_ReturnsValidRefreshToken() diff --git a/Govor.Application.Tests/Services/UserSessions/UserSessionOpenerTests.cs b/Govor.Application.Tests/Services/UserSessions/UserSessionOpenerTests.cs index 278c4f5..b08cc73 100644 --- a/Govor.Application.Tests/Services/UserSessions/UserSessionOpenerTests.cs +++ b/Govor.Application.Tests/Services/UserSessions/UserSessionOpenerTests.cs @@ -1,5 +1,4 @@ using Govor.Application.Services.UserSessions; -using Govor.Core.Models; using Govor.Core.Models.Users; using Govor.Core.Repositories.UserSessionsRepository; using Microsoft.Extensions.Logging; @@ -19,6 +18,7 @@ public class UserSessionOpenerTests private IOptions _options; private UserSessionOpener _service; private User _user; + private Guid _sessionId; private const string DeviceInfo = "Chrome on Windows"; private const string GeneratedToken = "new-refresh-token"; private const string NewAccessToken = "new-access-token"; @@ -30,8 +30,9 @@ public class UserSessionOpenerTests _jwtServiceMock = new Mock(); _loggerMock = new Mock>(); _options = Options.Create(new JwtRefreshOption { RefreshTokenLifetimeDays = 30 }); - - // Инициализируем пользователя ДО моков, чтобы не было null в Setup + + _sessionId = Guid.NewGuid(); + _user = new User { Id = Guid.NewGuid(), @@ -44,7 +45,7 @@ public class UserSessionOpenerTests }; _jwtServiceMock.Setup(j => j.GenerateRefreshTokenAsync(_user)).ReturnsAsync(GeneratedToken); - _jwtServiceMock.Setup(j => j.GenerateAccessTokenAsync(_user)).ReturnsAsync(NewAccessToken); + _jwtServiceMock.Setup(j => j.GenerateAccessTokenAsync(_user, _sessionId)).ReturnsAsync(NewAccessToken); _service = new UserSessionOpener( _repositoryMock.Object, @@ -60,6 +61,7 @@ public class UserSessionOpenerTests // Arrange var session = new UserSession { + Id = _sessionId, UserId = _user.Id, DeviceInfo = DeviceInfo, RefreshToken = "valid-token", @@ -87,6 +89,7 @@ public class UserSessionOpenerTests // Arrange var session = new UserSession { + Id = _sessionId, UserId = _user.Id, DeviceInfo = DeviceInfo, RefreshToken = "old-token", @@ -113,7 +116,8 @@ public class UserSessionOpenerTests { // Arrange _repositoryMock.Setup(r => r.GetByUserIdAsync(_user.Id)).ReturnsAsync(new List()); - + _jwtServiceMock.Setup(j => j.GenerateAccessTokenAsync(_user, It.IsAny())).ReturnsAsync(NewAccessToken); + // Act var result = await _service.OpenSessionAsync(_user, DeviceInfo); @@ -134,7 +138,8 @@ public class UserSessionOpenerTests _repositoryMock .Setup(r => r.GetByUserIdAsync(_user.Id)) .ThrowsAsync(new Govor.Data.Repositories.Exceptions.NotFoundByKeyException(_user.Id, "userId")); - + _jwtServiceMock.Setup(j => j.GenerateAccessTokenAsync(_user, It.IsAny())).ReturnsAsync(NewAccessToken); + // Act var result = await _service.OpenSessionAsync(_user, DeviceInfo); diff --git a/Govor.Application.Tests/Services/UserSessions/UserSessionRefresherTests.cs b/Govor.Application.Tests/Services/UserSessions/UserSessionRefresherTests.cs index 0577a5c..38fcacc 100644 --- a/Govor.Application.Tests/Services/UserSessions/UserSessionRefresherTests.cs +++ b/Govor.Application.Tests/Services/UserSessions/UserSessionRefresherTests.cs @@ -73,7 +73,7 @@ public class UserSessionRefresherTests // Arrange _sessionsRepoMock.Setup(r => r.GetByRefreshTokenAsync(OldRefreshToken)).ReturnsAsync(_session); _usersRepoMock.Setup(r => r.FindByIdAsync(_user.Id)).ReturnsAsync(_user); - _jwtServiceMock.Setup(j => j.GenerateAccessTokenAsync(_user)).ReturnsAsync(NewAccessToken); + _jwtServiceMock.Setup(j => j.GenerateAccessTokenAsync(_user, _session.Id)).ReturnsAsync(NewAccessToken); _jwtServiceMock.Setup(j => j.GenerateRefreshTokenAsync(_user)).ReturnsAsync(NewRefreshToken); // Act diff --git a/Govor.Application/Interfaces/Authentication/IJwtService.cs b/Govor.Application/Interfaces/Authentication/IJwtService.cs index 18c7477..d5d54ad 100644 --- a/Govor.Application/Interfaces/Authentication/IJwtService.cs +++ b/Govor.Application/Interfaces/Authentication/IJwtService.cs @@ -5,7 +5,7 @@ namespace Govor.Application.Interfaces.Authentication; public interface IJwtService { - Task GenerateAccessTokenAsync(User user); + Task GenerateAccessTokenAsync(User user, Guid sessionId); Task GenerateRefreshTokenAsync(User user); ClaimsPrincipal GetPrincipalFromExpiredToken(string token); } \ No newline at end of file diff --git a/Govor.Application/Services/Authentication/JwtService.cs b/Govor.Application/Services/Authentication/JwtService.cs index 12da414..764ded3 100644 --- a/Govor.Application/Services/Authentication/JwtService.cs +++ b/Govor.Application/Services/Authentication/JwtService.cs @@ -21,11 +21,12 @@ public class JwtService : IJwtService _invitesService = invitesService; } - public async Task GenerateAccessTokenAsync(User user) + public async Task GenerateAccessTokenAsync(User user, Guid sessionId) { var claims = new[] { new Claim("userId", user.Id.ToString()), + new Claim("sid", sessionId.ToString()), new Claim(ClaimTypes.Role, await _invitesService.GetRoleAsync(user), ClaimValueTypes.String) }; diff --git a/Govor.Application/Services/UserSessions/UserSessionOpener.cs b/Govor.Application/Services/UserSessions/UserSessionOpener.cs index 2d3d2be..0838eff 100644 --- a/Govor.Application/Services/UserSessions/UserSessionOpener.cs +++ b/Govor.Application/Services/UserSessions/UserSessionOpener.cs @@ -36,15 +36,13 @@ namespace Govor.Application.Services.UserSessions { var sessions = await _repository.GetByUserIdAsync(user.Id); var session = sessions.FirstOrDefault(s => s.DeviceInfo == deviceInfo); - - var newRefreshToken = await _jwtService.GenerateRefreshTokenAsync(user); - var accessToken = await _jwtService.GenerateAccessTokenAsync(user); - - var newExpiresAt = DateTime.UtcNow.AddDays(_options.RefreshTokenLifetimeDays); - + if (session is not null) { - // Всегда обновляем токен и дату + var newExpiresAt = DateTime.UtcNow.AddDays(_options.RefreshTokenLifetimeDays); + var accessToken = await _jwtService.GenerateAccessTokenAsync(user, session.Id); + var newRefreshToken = await _jwtService.GenerateRefreshTokenAsync(user); + session.RefreshToken = newRefreshToken; session.ExpiresAt = newExpiresAt; session.CreatedAt = DateTime.UtcNow; @@ -52,7 +50,7 @@ namespace Govor.Application.Services.UserSessions await _repository.UpdateAsync(session); _logger.LogInformation($"Updated session for user {user.Id} on device '{deviceInfo}'"); - + return new RefreshResult(session.RefreshToken, accessToken); } @@ -65,25 +63,29 @@ namespace Govor.Application.Services.UserSessions async Task OpenNewSession() { - var newRefreshToken = await _jwtService.GenerateRefreshTokenAsync(user); - var accessToken = await _jwtService.GenerateAccessTokenAsync(user); - + var sessionId = Guid.NewGuid(); + + var accessToken = await _jwtService.GenerateAccessTokenAsync(user, sessionId); + var refreshToken = await _jwtService.GenerateRefreshTokenAsync(user); + var newSession = new UserSession { + Id = sessionId, UserId = user.Id, DeviceInfo = deviceInfo, - RefreshToken = newRefreshToken, + RefreshToken = refreshToken, CreatedAt = DateTime.UtcNow, ExpiresAt = DateTime.UtcNow.AddDays(_options.RefreshTokenLifetimeDays), IsRevoked = false }; await _repository.AddAsync(newSession); - - _logger.LogInformation($"Created new session for user {user.Id} on device '{deviceInfo}'"); - return new RefreshResult(newRefreshToken, accessToken); + _logger.LogInformation($"Created new session {sessionId} for user {user.Id} on device '{deviceInfo}'"); + + return new RefreshResult(refreshToken, accessToken); } + } } } diff --git a/Govor.Application/Services/UserSessions/UserSessionRefresher.cs b/Govor.Application/Services/UserSessions/UserSessionRefresher.cs index b6a968f..701bd13 100644 --- a/Govor.Application/Services/UserSessions/UserSessionRefresher.cs +++ b/Govor.Application/Services/UserSessions/UserSessionRefresher.cs @@ -48,7 +48,7 @@ public class UserSessionRefresher : IUserSessionRefresher var user = await _usersRepository.FindByIdAsync(session.UserId); // New tokens - var newAccessToken = await _jwtService.GenerateAccessTokenAsync(user); + var newAccessToken = await _jwtService.GenerateAccessTokenAsync(user, session.Id); var newRefreshToken = await _jwtService.GenerateRefreshTokenAsync(user); // Opening new session diff --git a/Govor.Core/Models/Users/UserSession.cs b/Govor.Core/Models/Users/UserSession.cs index 62f8384..b9cf558 100644 --- a/Govor.Core/Models/Users/UserSession.cs +++ b/Govor.Core/Models/Users/UserSession.cs @@ -6,6 +6,8 @@ public class UserSession public Guid UserId { get; set; } public string RefreshToken { get; set; } = string.Empty; public string DeviceInfo { get; set; } = string.Empty; // "Chrome on Windows" + public string PublicEncryptionKey { get; set; } + public string PublicSigningKey { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime ExpiresAt { get; set; } public bool IsRevoked { get; set; } = false; From 751c30deddbe33956f93c215780a4eb7de29a482 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Fri, 25 Jul 2025 18:07:05 +0700 Subject: [PATCH 081/101] Add CurrentUserSessionService for session ID retrieval Introduces ICurrentUserSessionService and its implementation to provide access to the current user's session ID via the 'sid' claim. Registers the service in DI and adds comprehensive unit tests to validate correct and error scenarios. --- .../ConfigurationProgramExtensions.cs | 1 + .../Extensions/CurrentUserServiceTests.cs | 3 +- .../CurrentUserSessionServiceTests.cs | 109 ++++++++++++++++++ .../Extensions/CurrentUserSessionService.cs | 27 +++++ .../Extensions/ICurrentUserSessionService.cs | 6 + 5 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 Govor.Application.Tests/Infrastructure/Extensions/CurrentUserSessionServiceTests.cs create mode 100644 Govor.Application/Infrastructure/Extensions/CurrentUserSessionService.cs create mode 100644 Govor.Application/Interfaces/Infrastructure/Extensions/ICurrentUserSessionService.cs diff --git a/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs index 4795c58..58c622f 100644 --- a/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs +++ b/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs @@ -64,6 +64,7 @@ public static class ConfigurationProgramExtensions services.AddHttpContextAccessor(); // it's very important for CurrentUserService services.AddScoped(); + services.AddScoped(); services.AddMemoryCache(); services.AddScoped(); diff --git a/Govor.Application.Tests/Infrastructure/Extensions/CurrentUserServiceTests.cs b/Govor.Application.Tests/Infrastructure/Extensions/CurrentUserServiceTests.cs index 69b8fbc..31fc66b 100644 --- a/Govor.Application.Tests/Infrastructure/Extensions/CurrentUserServiceTests.cs +++ b/Govor.Application.Tests/Infrastructure/Extensions/CurrentUserServiceTests.cs @@ -1,5 +1,6 @@ using System.Security.Claims; using Govor.Application.Infrastructure.Extensions; +using Govor.Application.Interfaces.Infrastructure.Extensions; using Microsoft.AspNetCore.Http; using Moq; @@ -9,7 +10,7 @@ namespace Govor.Application.Tests.Infrastructure.Extensions; public class CurrentUserServiceTests { private Mock _httpContextAccessorMock; - private CurrentUserService _currentUserService; + private ICurrentUserService _currentUserService; [SetUp] public void SetUp() diff --git a/Govor.Application.Tests/Infrastructure/Extensions/CurrentUserSessionServiceTests.cs b/Govor.Application.Tests/Infrastructure/Extensions/CurrentUserSessionServiceTests.cs new file mode 100644 index 0000000..c9c42e5 --- /dev/null +++ b/Govor.Application.Tests/Infrastructure/Extensions/CurrentUserSessionServiceTests.cs @@ -0,0 +1,109 @@ +using System.Security.Claims; +using Govor.Application.Infrastructure.Extensions; +using Govor.Application.Interfaces.Infrastructure.Extensions; +using Microsoft.AspNetCore.Http; +using Moq; + +namespace Govor.Application.Tests.Infrastructure.Extensions; + +[TestFixture] +[TestOf(typeof(CurrentUserSessionService))] +public class CurrentUserSessionServiceTests +{ + private Mock _httpContextAccessorMock; + private ICurrentUserSessionService _sessionService; + + [SetUp] + public void SetUp() + { + _httpContextAccessorMock = new Mock(); + _sessionService = new CurrentUserSessionService(_httpContextAccessorMock.Object); + } + + [Test] + public void GetCurrentSessionId_ValidSidClaim_ReturnsGuid() + { + // Arrange + var sid = Guid.NewGuid(); + var claims = new[] { new Claim("sid", sid.ToString()) }; + var identity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(identity); + + var httpContextMock = new Mock(); + httpContextMock.Setup(x => x.User).Returns(principal); + + _httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContextMock.Object); + + // Act + var result = _sessionService.GetUserSessionId(); + + // Assert + Assert.That(result, Is.EqualTo(sid)); + } + + [Test] + public void GetCurrentSessionId_NoHttpContext_ThrowsUnauthorizedAccessException() + { + // Arrange + _httpContextAccessorMock.Setup(x => x.HttpContext).Returns((HttpContext)null); + + // Act & Assert + var ex = Assert.Throws(() => _sessionService.GetUserSessionId()); + Assert.That(ex.Message, Is.EqualTo("Session id (sid) claim is missing or invalid")); + } + + + [Test] + public void GetCurrentSessionId_NoSidlaim_ThrowsUnauthorizedAccessException() + { + // Arrange + var claims = new[] { new Claim("otherClaim", "value") }; + var identity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(identity); + + var httpContextMock = new Mock(); + httpContextMock.Setup(x => x.User).Returns(principal); + + _httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContextMock.Object); + + // Act & Assert + var ex = Assert.Throws(() => _sessionService.GetUserSessionId()); + Assert.That(ex.Message, Is.EqualTo("Session id (sid) claim is missing or invalid")); + } + + [Test] + public void GetUserSessionId_InvalidSidValueClaim_ThrowsUnauthorizedAccessException() + { + // Arrange + var claims = new[] { new Claim("sid", "invalid-guid") }; + var identity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(identity); + + var httpContextMock = new Mock(); + httpContextMock.Setup(x => x.User).Returns(principal); + + _httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContextMock.Object); + + // Act & Assert + var ex = Assert.Throws(() => _sessionService.GetUserSessionId()); + Assert.That(ex.Message, Is.EqualTo("Session id (sid) claim is missing or invalid")); + } + + [Test] + public void GetUserSessionId_EmptySidValueClaim_ThrowsUnauthorizedAccessException() + { + // Arrange + var claims = new[] { new Claim("sid", "") }; + var identity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(identity); + + var httpContextMock = new Mock(); + httpContextMock.Setup(x => x.User).Returns(principal); + + _httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContextMock.Object); + + // Act & Assert + var ex = Assert.Throws(() => _sessionService.GetUserSessionId()); + Assert.That(ex.Message, Is.EqualTo("Session id (sid) claim is missing or invalid")); + } +} \ No newline at end of file diff --git a/Govor.Application/Infrastructure/Extensions/CurrentUserSessionService.cs b/Govor.Application/Infrastructure/Extensions/CurrentUserSessionService.cs new file mode 100644 index 0000000..f986ab4 --- /dev/null +++ b/Govor.Application/Infrastructure/Extensions/CurrentUserSessionService.cs @@ -0,0 +1,27 @@ +using Govor.Application.Interfaces.Infrastructure.Extensions; +using Microsoft.AspNetCore.Http; + +namespace Govor.Application.Infrastructure.Extensions; + +public class CurrentUserSessionService : ICurrentUserSessionService +{ + private readonly IHttpContextAccessor _httpContextAccessor; + + public CurrentUserSessionService(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + public Guid GetUserSessionId() + { + var user = _httpContextAccessor.HttpContext?.User; + var userIdClaim = user?.FindFirst("sid")?.Value; + + if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) + { + throw new UnauthorizedAccessException("Session id (sid) claim is missing or invalid"); + } + + return userId; + } +} \ No newline at end of file diff --git a/Govor.Application/Interfaces/Infrastructure/Extensions/ICurrentUserSessionService.cs b/Govor.Application/Interfaces/Infrastructure/Extensions/ICurrentUserSessionService.cs new file mode 100644 index 0000000..f4000a9 --- /dev/null +++ b/Govor.Application/Interfaces/Infrastructure/Extensions/ICurrentUserSessionService.cs @@ -0,0 +1,6 @@ +namespace Govor.Application.Interfaces.Infrastructure.Extensions; + +public interface ICurrentUserSessionService +{ + Guid GetUserSessionId(); +} \ No newline at end of file From dc560ba698a988d1165f9d609d6f179c3bda1502 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Wed, 30 Jul 2025 12:41:57 +0700 Subject: [PATCH 082/101] fix docs --- .../Authentication/SessionKeysController.cs | 32 +++++++++++++++++-- .../UserSession/ISessionKeyService.cs | 24 ++++++++++++++ Govor.Contracts/Requests/UploadKeysRequest.cs | 7 ++++ docs/SUMMARY.md | 28 ++++++++-------- 4 files changed, 74 insertions(+), 17 deletions(-) create mode 100644 Govor.Application/Interfaces/UserSession/ISessionKeyService.cs create mode 100644 Govor.Contracts/Requests/UploadKeysRequest.cs diff --git a/Govor.API/Controllers/Authentication/SessionKeysController.cs b/Govor.API/Controllers/Authentication/SessionKeysController.cs index b001c70..d04fa75 100644 --- a/Govor.API/Controllers/Authentication/SessionKeysController.cs +++ b/Govor.API/Controllers/Authentication/SessionKeysController.cs @@ -1,3 +1,5 @@ +using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.Contracts.Requests; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -9,9 +11,33 @@ namespace Govor.API.Controllers.Authentication; [Authorize(Roles = "Admin, User")] public class SessionKeysController : Controller { - // GET - public IActionResult Index() + private readonly ILogger _logger; + private readonly ICurrentUserSessionService _currentSession; + private readonly ICurrentUserService _currentUser; + /* + [HttpPost("keys")] + public async Task UploadSessionKeys([FromBody] UploadKeysRequest request) { - return View(); + var sessionId = _currentSession.GetUserSessionId(); + + await _sessionKeyService.AttachKeysAsync(sessionId, request.PublicEncryptionKey, request.PublicSigningKey); + + return Ok(); } + + [Authorize] + [HttpGet("users/{userId}/keys")] + public async Task GetUserPublicKeys(Guid userId) + { + var requesterId = _currentUserService.UserId; + + if (!await _friendshipService.AreFriendsAsync(requesterId, userId)) + return Forbid("You can only access keys of your friends"); + + var keys = await _sessionKeyService.GetAllActiveKeysAsync(userId); + + return Ok(keys); + } + + */ } \ No newline at end of file diff --git a/Govor.Application/Interfaces/UserSession/ISessionKeyService.cs b/Govor.Application/Interfaces/UserSession/ISessionKeyService.cs new file mode 100644 index 0000000..76513b4 --- /dev/null +++ b/Govor.Application/Interfaces/UserSession/ISessionKeyService.cs @@ -0,0 +1,24 @@ +namespace Govor.Application.Interfaces.UserSession; + +public interface ISessionKeyService +{ + /// + /// Привязать публичные ключи текущего клиента к сессии. + /// + Task AttachKeysAsync(Guid userId, Guid sessionId, string publicEncryptionKey, string publicSigningKey); + + Task GetKeysAsync(Guid userId, Guid sessionId); + + /// + /// Получить публичные ключи пользователя (например, последнюю активную сессию или все активные). + /// + Task> GetAllActiveKeysAsync(Guid userId); +} + +public class SessionPublicKeys +{ + public Guid UserId { get; set; } + public Guid SessionId { get; set; } + public string PublicEncryptionKey { get; set; } = string.Empty; + public string PublicSigningKey { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/Govor.Contracts/Requests/UploadKeysRequest.cs b/Govor.Contracts/Requests/UploadKeysRequest.cs new file mode 100644 index 0000000..c62cc78 --- /dev/null +++ b/Govor.Contracts/Requests/UploadKeysRequest.cs @@ -0,0 +1,7 @@ +namespace Govor.Contracts.Requests; + +public class UploadKeysRequest +{ + public string PublicEncryptionKey { get; set; } + public string PublicSigningKey { get; set; } +} \ No newline at end of file diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 145002e..8885cc7 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -4,26 +4,26 @@ ## Endpoints -* [Authentication](../endpoints/authentication/README.md) - * [AuthController](../endpoints/authentication/authcontroller.md) - * [RefreshController](../endpoints/authentication/refreshcontroller.md) -* [SessionController](endpoints/sessioncontroller.md) -* [FriendshipController](../endpoints/friendshipcontroller.md) -* [FriendsRequestQueryController](../endpoints/friendsrequestquerycontroller.md) -* [MediaController](../endpoints/mediacontroller.md) -* [ChatLoadController](../endpoints/chatloadcontroller.md) -* [OnlinePingingController(не работает)](../endpoints/onlinepingingcontroller-ne-rabotaet.md) +* [Authentication](../docs/endpoints/authentication/README.md) + * [AuthController](../docs/endpoints/authentication/authcontroller.md) + * [RefreshController](../docs/endpoints/authentication/refreshcontroller.md) +* [SessionController](../docs/endpoints/sessioncontroller.md) +* [FriendshipController](../docs/endpoints/friendshipcontroller.md) +* [FriendsRequestQueryController](../docs/endpoints/friendsrequestquerycontroller.md) +* [MediaController](../docs/endpoints/mediacontroller.md) +* [ChatLoadController](../docs/endpoints/chatloadcontroller.md) +* [OnlinePingingController(не работает)](../docs/endpoints/onlinepingingcontroller-ne-rabotaet.md) ## SignalR -* [PresenceHub](../signalr/presencehub.md) -* [ChatHub](../signalr/chathub.md) +* [PresenceHub](../docs/signalr/presencehub.md) +* [ChatHub](../docs/signalr/chathub.md) *** -* [FriendsHub](../friendshub/README.md) - * [FriendsHub Client (Java)](../friendshub/friendshub-client-java.md) +* [FriendsHub](../docs/friendshub/README.md) + * [FriendsHub Client (Java)](../docs/friendshub/friendshub-client-java.md) ## Code Docs -* [HubResult\](../code-docs/hubresult-less-than-t-greater-than.md) +* [HubResult\](../docs/code-docs/hubresult-less-than-t-greater-than.md) From 1d442d037c01d4368cc16c58c0da0b156aa7c74e Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Wed, 30 Jul 2025 21:26:35 +0700 Subject: [PATCH 083/101] Add cryptographic session key management for users Introduces a new cryptographic key management system for user sessions, including models, DTOs, interfaces, and services for handling identity keys, signed pre-keys, and one-time pre-keys. Updates the SessionKeysController and DI configuration to use the new services. Adds related EF Core configurations and migrations, and updates the UserSession model to reference the new UserCryptoSession. Removes the obsolete ISessionKeyService interface. --- .../ConfigurationProgramExtensions.cs | 6 + .../Authentication/SessionKeysController.cs | 81 +- Govor.API/appsettings.json | 3 + .../Crypto/IOneTimePreKeysRotator.cs | 8 + .../UserSession/Crypto/ISessionKeyAttacher.cs | 13 + .../UserSession/Crypto/ISessionKeysReader.cs | 11 + .../UserSession/ISessionKeyService.cs | 24 - .../Crypto/OneTimePreKeysRotator.cs | 70 ++ .../UserSessions/Crypto/SessionKeyAttacher.cs | 73 ++ .../UserSessions/Crypto/SessionKeysReader.cs | 62 ++ Govor.Contracts/DTOs/OneTimePreKeyDto.cs | 7 + Govor.Contracts/DTOs/PublicSessionKeysDto.cs | 8 + Govor.Contracts/DTOs/SignedPreKeyDto.cs | 8 + .../Requests/RotateOneTimePreKeysRequest.cs | 6 + Govor.Contracts/Requests/UploadKeysRequest.cs | 6 +- .../Models/Users/Crypto/OneTimePreKey.cs | 12 + .../Models/Users/Crypto/SignedPreKey.cs | 10 + .../Models/Users/Crypto/UserCryptoSession.cs | 14 + Govor.Core/Models/Users/UserSession.cs | 6 +- .../OneTimePreKeyConfiguration.cs | 33 + .../SignedPreKeyConfiguration.cs | 27 + .../UserCryptoSessionConfiguration.cs | 37 + .../UserSessionConfiguration.cs | 8 +- Govor.Data/GovorDbContext.cs | 9 +- .../20250730142221_CryptKeys.Designer.cs | 763 ++++++++++++++++++ .../Migrations/20250730142221_CryptKeys.cs | 170 ++++ .../Migrations/GovorDbContextModelSnapshot.cs | 216 +++-- 27 files changed, 1608 insertions(+), 83 deletions(-) create mode 100644 Govor.Application/Interfaces/UserSession/Crypto/IOneTimePreKeysRotator.cs create mode 100644 Govor.Application/Interfaces/UserSession/Crypto/ISessionKeyAttacher.cs create mode 100644 Govor.Application/Interfaces/UserSession/Crypto/ISessionKeysReader.cs delete mode 100644 Govor.Application/Interfaces/UserSession/ISessionKeyService.cs create mode 100644 Govor.Application/Services/UserSessions/Crypto/OneTimePreKeysRotator.cs create mode 100644 Govor.Application/Services/UserSessions/Crypto/SessionKeyAttacher.cs create mode 100644 Govor.Application/Services/UserSessions/Crypto/SessionKeysReader.cs create mode 100644 Govor.Contracts/DTOs/OneTimePreKeyDto.cs create mode 100644 Govor.Contracts/DTOs/PublicSessionKeysDto.cs create mode 100644 Govor.Contracts/DTOs/SignedPreKeyDto.cs create mode 100644 Govor.Contracts/Requests/RotateOneTimePreKeysRequest.cs create mode 100644 Govor.Core/Models/Users/Crypto/OneTimePreKey.cs create mode 100644 Govor.Core/Models/Users/Crypto/SignedPreKey.cs create mode 100644 Govor.Core/Models/Users/Crypto/UserCryptoSession.cs create mode 100644 Govor.Data/Configurations/OneTimePreKeyConfiguration.cs create mode 100644 Govor.Data/Configurations/SignedPreKeyConfiguration.cs create mode 100644 Govor.Data/Configurations/UserCryptoSessionConfiguration.cs create mode 100644 Govor.Data/Migrations/20250730142221_CryptKeys.Designer.cs create mode 100644 Govor.Data/Migrations/20250730142221_CryptKeys.cs diff --git a/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs index 58c622f..c6782aa 100644 --- a/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs +++ b/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs @@ -11,6 +11,7 @@ using Govor.Application.Interfaces.Medias; using Govor.Application.Interfaces.Messages; using Govor.Application.Interfaces.UserOnlineStatus; using Govor.Application.Interfaces.UserSession; +using Govor.Application.Interfaces.UserSession.Crypto; using Govor.Application.Services; using Govor.Application.Services.Authentication; using Govor.Application.Services.Friends; @@ -18,6 +19,7 @@ using Govor.Application.Services.Medias; using Govor.Application.Services.Messages; using Govor.Application.Services.UserOnlineStatus; using Govor.Application.Services.UserSessions; +using Govor.Application.Services.UserSessions.Crypto; using Govor.Core.Infrastructure.Extensions; using Govor.Core.Infrastructure.Validators; using Govor.Core.Models; @@ -91,6 +93,10 @@ public static class ConfigurationProgramExtensions services.AddScoped(); services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } public static void AddRepositories(this IServiceCollection services) diff --git a/Govor.API/Controllers/Authentication/SessionKeysController.cs b/Govor.API/Controllers/Authentication/SessionKeysController.cs index d04fa75..77929ec 100644 --- a/Govor.API/Controllers/Authentication/SessionKeysController.cs +++ b/Govor.API/Controllers/Authentication/SessionKeysController.cs @@ -1,4 +1,7 @@ +using Govor.Application.Interfaces.Friends; using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.Application.Interfaces.UserSession; +using Govor.Application.Interfaces.UserSession.Crypto; using Govor.Contracts.Requests; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -12,32 +15,96 @@ namespace Govor.API.Controllers.Authentication; public class SessionKeysController : Controller { private readonly ILogger _logger; + private readonly IFriendshipService _friendshipService; private readonly ICurrentUserSessionService _currentSession; + private readonly ISessionKeyAttacher _sessionKeyAttacher; + private readonly ISessionKeysReader _sessionKeysReader; + private readonly IOneTimePreKeysRotator _oneTimePreKeysRotator; private readonly ICurrentUserService _currentUser; - /* + + public SessionKeysController( + ILogger logger, + IFriendshipService friendshipService, + ICurrentUserSessionService currentSession, + ISessionKeyAttacher sessionKeyAttacher, + ISessionKeysReader sessionKeysReader, + ICurrentUserService currentUser) + { + _logger = logger; + _friendshipService = friendshipService; + _currentSession = currentSession; + _sessionKeyAttacher = sessionKeyAttacher; + _sessionKeysReader = sessionKeysReader; + _currentUser = currentUser; + } + [HttpPost("keys")] public async Task UploadSessionKeys([FromBody] UploadKeysRequest request) { + if (!ModelState.IsValid) + return BadRequest(ModelState); + + if(request.OneTimePreKeys.Count > 100) + return BadRequest("Too many one time pre keys"); + var sessionId = _currentSession.GetUserSessionId(); - await _sessionKeyService.AttachKeysAsync(sessionId, request.PublicEncryptionKey, request.PublicSigningKey); + await _sessionKeyAttacher.AttachKeysAsync(sessionId, + request.IdentityKey, + request.SignedPreKey, + request.SignedPreKeySignature, + request.OneTimePreKeys); return Ok(); } - [Authorize] [HttpGet("users/{userId}/keys")] public async Task GetUserPublicKeys(Guid userId) { - var requesterId = _currentUserService.UserId; + var requesterId = _currentUser.GetCurrentUserId(); - if (!await _friendshipService.AreFriendsAsync(requesterId, userId)) + if (!(await _friendshipService.GetFriendsAsync(userId)).Select(u => u.Id).Contains(requesterId)) return Forbid("You can only access keys of your friends"); - var keys = await _sessionKeyService.GetAllActiveKeysAsync(userId); + var keys = await _sessionKeysReader.GetAllActiveKeysAsync(userId); return Ok(keys); } + + [HttpPost("keys/rotate")] + public async Task RotateOneTimePreKeys([FromBody] RotateOneTimePreKeysRequest request) + { + if (!ModelState.IsValid) + return BadRequest(ModelState); + + if (request.NewOneTimePreKeys.Count > 100) + return BadRequest("Too many new one time pre keys"); + + var sessionId = _currentSession.GetUserSessionId(); + + await _oneTimePreKeysRotator.RotateOneTimePreKeysAsync(sessionId, request.NewOneTimePreKeys); + + return Ok("One-Time PreKeys rotated successfully."); + } + + [HttpGet("keys/remaining")] + public async Task GetRemainingOneTimePreKeysCount() + { + var sessionId = _currentSession.GetUserSessionId(); + + var count = await _sessionKeysReader.GetRemainingOneTimePreKeysCountAsync(sessionId); + + return Ok(new { remaining = count }); + } + + [HttpPost("keys/{preKeyId}/used")] + public async Task MarkOneTimePreKeyAsUsed([FromRoute] Guid preKeyId) + { + var sessionId = _currentSession.GetUserSessionId(); + + await _oneTimePreKeysRotator.MarkOneTimePreKeyAsUsedAsync(sessionId, preKeyId); + + return Ok("Marked as used."); + } - */ } \ No newline at end of file diff --git a/Govor.API/appsettings.json b/Govor.API/appsettings.json index 7bdb15b..ce20070 100644 --- a/Govor.API/appsettings.json +++ b/Govor.API/appsettings.json @@ -16,5 +16,8 @@ }, "JwtRefreshOption": { "RefreshTokenLifetimeDays": 30 + }, + "EncryptionOption": { + "Secret": "4r8B2j9kP5mX7nQwE2zY3A==" } } diff --git a/Govor.Application/Interfaces/UserSession/Crypto/IOneTimePreKeysRotator.cs b/Govor.Application/Interfaces/UserSession/Crypto/IOneTimePreKeysRotator.cs new file mode 100644 index 0000000..9bf0865 --- /dev/null +++ b/Govor.Application/Interfaces/UserSession/Crypto/IOneTimePreKeysRotator.cs @@ -0,0 +1,8 @@ +namespace Govor.Application.Interfaces.UserSession.Crypto; + +public interface IOneTimePreKeysRotator +{ + Task RotateOneTimePreKeysAsync(Guid sessionId, IEnumerable newOneTimePreKeys); + + Task MarkOneTimePreKeyAsUsedAsync(Guid sessionId, Guid oneTimePreKeyId); +} \ No newline at end of file diff --git a/Govor.Application/Interfaces/UserSession/Crypto/ISessionKeyAttacher.cs b/Govor.Application/Interfaces/UserSession/Crypto/ISessionKeyAttacher.cs new file mode 100644 index 0000000..d08db89 --- /dev/null +++ b/Govor.Application/Interfaces/UserSession/Crypto/ISessionKeyAttacher.cs @@ -0,0 +1,13 @@ +using Govor.Core.Models.Users.Crypto; + +namespace Govor.Application.Interfaces.UserSession.Crypto; + +public interface ISessionKeyAttacher +{ + Task AttachKeysAsync( + Guid sessionId, + byte[] identityKey, + byte[] signedPreKey, + byte[] signedPreKeySignature, + IEnumerable oneTimePreKeys); +} \ No newline at end of file diff --git a/Govor.Application/Interfaces/UserSession/Crypto/ISessionKeysReader.cs b/Govor.Application/Interfaces/UserSession/Crypto/ISessionKeysReader.cs new file mode 100644 index 0000000..5a0a23f --- /dev/null +++ b/Govor.Application/Interfaces/UserSession/Crypto/ISessionKeysReader.cs @@ -0,0 +1,11 @@ +using Govor.Core.Models.Users.Crypto; + +namespace Govor.Application.Interfaces.UserSession.Crypto; + +public interface ISessionKeysReader +{ + Task HasKeysAttachedAsync(Guid sessionId); + Task> GetAllActiveKeysAsync(Guid userId); + Task GetRemainingOneTimePreKeysCountAsync(Guid sessionId); + Task GetKeysBySessionIdAsync(Guid sessionId); +} \ No newline at end of file diff --git a/Govor.Application/Interfaces/UserSession/ISessionKeyService.cs b/Govor.Application/Interfaces/UserSession/ISessionKeyService.cs deleted file mode 100644 index 76513b4..0000000 --- a/Govor.Application/Interfaces/UserSession/ISessionKeyService.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace Govor.Application.Interfaces.UserSession; - -public interface ISessionKeyService -{ - /// - /// Привязать публичные ключи текущего клиента к сессии. - /// - Task AttachKeysAsync(Guid userId, Guid sessionId, string publicEncryptionKey, string publicSigningKey); - - Task GetKeysAsync(Guid userId, Guid sessionId); - - /// - /// Получить публичные ключи пользователя (например, последнюю активную сессию или все активные). - /// - Task> GetAllActiveKeysAsync(Guid userId); -} - -public class SessionPublicKeys -{ - public Guid UserId { get; set; } - public Guid SessionId { get; set; } - public string PublicEncryptionKey { get; set; } = string.Empty; - public string PublicSigningKey { get; set; } = string.Empty; -} \ No newline at end of file diff --git a/Govor.Application/Services/UserSessions/Crypto/OneTimePreKeysRotator.cs b/Govor.Application/Services/UserSessions/Crypto/OneTimePreKeysRotator.cs new file mode 100644 index 0000000..0e5856c --- /dev/null +++ b/Govor.Application/Services/UserSessions/Crypto/OneTimePreKeysRotator.cs @@ -0,0 +1,70 @@ +using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.Application.Interfaces.UserSession.Crypto; +using Govor.Core.Models.Users.Crypto; +using Govor.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Govor.Application.Services.UserSessions.Crypto; + +public class OneTimePreKeysRotator : IOneTimePreKeysRotator +{ + private readonly ILogger _logger; + private readonly ICurrentUserService _current; + private readonly GovorDbContext _context; + + public OneTimePreKeysRotator(ILogger logger, ICurrentUserService current, GovorDbContext context) + { + _logger = logger; + _current = current; + _context = context; + } + + public async Task RotateOneTimePreKeysAsync(Guid sessionId, IEnumerable newOneTimePreKeys) + { + var cryptoSession = await _context.UserCryptoSessions + .Include(c => c.OneTimePreKeys) + .FirstOrDefaultAsync(c => c.UserSessionId == sessionId); + + if (cryptoSession == null) + throw new ArgumentException("Crypto session not found", nameof(sessionId)); + + // Удаляем все использованные ключи + var usedKeys = cryptoSession.OneTimePreKeys.Where(k => k.IsUsed).ToList(); + if (usedKeys.Any()) + { + _context.OneTimePreKeys.RemoveRange(usedKeys); + } + + // Добавляем новые ключи (возможно, стоит ограничить количество) + foreach (var key in newOneTimePreKeys) + { + cryptoSession.OneTimePreKeys.Add(new OneTimePreKey + { + Id = Guid.NewGuid(), + PublicKey = key, + IsUsed = false, + UploadedAt = DateTime.UtcNow + }); + } + + await _context.SaveChangesAsync(); + } + + public async Task MarkOneTimePreKeyAsUsedAsync(Guid sessionId, Guid oneTimePreKeyId) + { + var key = await _context.OneTimePreKeys + .Include(k => k.UserCryptoSession) + .FirstOrDefaultAsync(k => k.Id == oneTimePreKeyId && k.UserCryptoSession.UserSessionId == sessionId); + + if (key == null) + throw new ArgumentException("One-Time PreKey not found for this session", nameof(oneTimePreKeyId)); + + if (key.IsUsed) + return; // Уже помечен + + key.IsUsed = true; + + await _context.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/Govor.Application/Services/UserSessions/Crypto/SessionKeyAttacher.cs b/Govor.Application/Services/UserSessions/Crypto/SessionKeyAttacher.cs new file mode 100644 index 0000000..1a42da3 --- /dev/null +++ b/Govor.Application/Services/UserSessions/Crypto/SessionKeyAttacher.cs @@ -0,0 +1,73 @@ +using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.Application.Interfaces.UserSession.Crypto; +using Govor.Core.Models.Users.Crypto; +using Govor.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Govor.Application.Services.UserSessions.Crypto; + +public class SessionKeyAttacher : ISessionKeyAttacher +{ + private readonly ILogger _logger; + private readonly ICurrentUserService _current; + private readonly GovorDbContext _context; + + public SessionKeyAttacher(ILogger logger, ICurrentUserService current, GovorDbContext context) + { + _logger = logger; + _current = current; + _context = context; + } + + public async Task AttachKeysAsync( + Guid sessionId, + byte[] identityKey, + byte[] signedPreKey, + byte[] signedPreKeySignature, + IEnumerable oneTimePreKeys) + { + var session = await _context.UserSessions + .Include(s => s.CryptoSession) + .ThenInclude(c => c.OneTimePreKeys) + .FirstOrDefaultAsync(s => s.Id == sessionId); + + if (session == null) + throw new ArgumentException("Session not found", nameof(sessionId)); + + if (session.CryptoSession != null) + throw new InvalidOperationException("Keys are already attached to this session"); + + if(session.UserId != _current.GetCurrentUserId()) + throw new InvalidOperationException("You cannot attach keys to this session"); + + var cryptoSessionId = Guid.NewGuid(); + + var cryptoSession = new UserCryptoSession + { + Id = cryptoSessionId, + UserSessionId = sessionId, + PublicIdentityKey = identityKey, + SignedPreKey = new SignedPreKey() + { + Id = Guid.NewGuid(), + UserCryptoSessionId = cryptoSessionId, + PublicSignedPreKey = signedPreKey, + SignedPreKeySignature = signedPreKeySignature, + }, + OneTimePreKeys = oneTimePreKeys.Select(key => new OneTimePreKey + { + Id = Guid.NewGuid(), + PublicKey = key, + IsUsed = false, + UploadedAt = DateTime.UtcNow + }).ToList() + }; + + session.CryptoSession = cryptoSession; + + await _context.SaveChangesAsync(); + + _logger.LogInformation("Attached keys to session {SessionId}", sessionId); + } +} \ No newline at end of file diff --git a/Govor.Application/Services/UserSessions/Crypto/SessionKeysReader.cs b/Govor.Application/Services/UserSessions/Crypto/SessionKeysReader.cs new file mode 100644 index 0000000..8a9e81a --- /dev/null +++ b/Govor.Application/Services/UserSessions/Crypto/SessionKeysReader.cs @@ -0,0 +1,62 @@ +using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.Application.Interfaces.UserSession.Crypto; +using Govor.Core.Models.Users.Crypto; +using Govor.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Govor.Application.Services.UserSessions.Crypto; + +public class SessionKeysReader : ISessionKeysReader +{ + private readonly ILogger _logger; + private readonly GovorDbContext _context; + + public SessionKeysReader(ILogger logger, GovorDbContext context) + { + _logger = logger; + _context = context; + } + + public async Task HasKeysAttachedAsync(Guid sessionId) + { + return await _context.UserCryptoSessions.AnyAsync(c => c.UserSessionId == sessionId); + } + + public async Task> GetAllActiveKeysAsync(Guid userId) + { + _logger.LogInformation("Getting all active keys for user {UserId}.", userId); + + var now = DateTime.UtcNow; + + return await _context.UserCryptoSessions + .AsNoTracking() + .Include(c => c.OneTimePreKeys) + .Include(c => c.UserSession) + .Where(c => c.UserSession.UserId == userId + && !c.UserSession.IsRevoked + && c.UserSession.ExpiresAt > now) + .ToListAsync(); + } + + public async Task GetRemainingOneTimePreKeysCountAsync(Guid sessionId) + { + _logger.LogInformation("Getting count of one time pre keys for session {UserId}.", sessionId); + + return await _context.OneTimePreKeys + .AsNoTracking() + .Include(f => f.UserCryptoSession) + .CountAsync(f => f.UserCryptoSession.UserSessionId == sessionId); + } + + public async Task GetKeysBySessionIdAsync(Guid sessionId) + { + _logger.LogInformation("Getting keys for session {session}.", sessionId); + + return await _context.UserCryptoSessions + .AsNoTracking() + .Include(c => c.OneTimePreKeys) + .Include(c => c.UserSession) + .FirstOrDefaultAsync(c => c.UserSessionId == sessionId); + } +} \ No newline at end of file diff --git a/Govor.Contracts/DTOs/OneTimePreKeyDto.cs b/Govor.Contracts/DTOs/OneTimePreKeyDto.cs new file mode 100644 index 0000000..5c2d087 --- /dev/null +++ b/Govor.Contracts/DTOs/OneTimePreKeyDto.cs @@ -0,0 +1,7 @@ +namespace Govor.Contracts.DTOs; + +public class OneTimePreKeyDto +{ + public Guid Id { get; set; } + public string Key { get; set; } = string.Empty; +} diff --git a/Govor.Contracts/DTOs/PublicSessionKeysDto.cs b/Govor.Contracts/DTOs/PublicSessionKeysDto.cs new file mode 100644 index 0000000..96a14a8 --- /dev/null +++ b/Govor.Contracts/DTOs/PublicSessionKeysDto.cs @@ -0,0 +1,8 @@ +namespace Govor.Contracts.DTOs; + +public class PublicSessionKeysDto +{ + public string IdentityKey { get; set; } = string.Empty; // base64 + public SignedPreKeyDto SignedPreKey { get; set; } + public List OneTimePreKeys { get; set; } = new(); +} diff --git a/Govor.Contracts/DTOs/SignedPreKeyDto.cs b/Govor.Contracts/DTOs/SignedPreKeyDto.cs new file mode 100644 index 0000000..798acd5 --- /dev/null +++ b/Govor.Contracts/DTOs/SignedPreKeyDto.cs @@ -0,0 +1,8 @@ +namespace Govor.Contracts.DTOs; + +public class SignedPreKeyDto +{ + public Guid Id { get; set; } + public string Key { get; set; } = string.Empty; + public string Signature { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/Govor.Contracts/Requests/RotateOneTimePreKeysRequest.cs b/Govor.Contracts/Requests/RotateOneTimePreKeysRequest.cs new file mode 100644 index 0000000..6553573 --- /dev/null +++ b/Govor.Contracts/Requests/RotateOneTimePreKeysRequest.cs @@ -0,0 +1,6 @@ +namespace Govor.Contracts.Requests; + +public class RotateOneTimePreKeysRequest +{ + public ICollection NewOneTimePreKeys { get; set; } = new List(); +} diff --git a/Govor.Contracts/Requests/UploadKeysRequest.cs b/Govor.Contracts/Requests/UploadKeysRequest.cs index c62cc78..f8d48ff 100644 --- a/Govor.Contracts/Requests/UploadKeysRequest.cs +++ b/Govor.Contracts/Requests/UploadKeysRequest.cs @@ -2,6 +2,8 @@ namespace Govor.Contracts.Requests; public class UploadKeysRequest { - public string PublicEncryptionKey { get; set; } - public string PublicSigningKey { get; set; } + public byte[] IdentityKey { get; set; } + public byte[] SignedPreKey { get; set; } + public byte[] SignedPreKeySignature { get; set; } + public List OneTimePreKeys { get; set; } = new(); } \ No newline at end of file diff --git a/Govor.Core/Models/Users/Crypto/OneTimePreKey.cs b/Govor.Core/Models/Users/Crypto/OneTimePreKey.cs new file mode 100644 index 0000000..b356bb3 --- /dev/null +++ b/Govor.Core/Models/Users/Crypto/OneTimePreKey.cs @@ -0,0 +1,12 @@ +namespace Govor.Core.Models.Users.Crypto; + +public class OneTimePreKey +{ + public Guid Id { get; set; } + public Guid UserCryptoSessionId { get; set; } + public UserCryptoSession UserCryptoSession { get; set; } + + public byte[] PublicKey { get; set; } + public bool IsUsed { get; set; } + public DateTime UploadedAt { get; set; } = DateTime.UtcNow; +} diff --git a/Govor.Core/Models/Users/Crypto/SignedPreKey.cs b/Govor.Core/Models/Users/Crypto/SignedPreKey.cs new file mode 100644 index 0000000..c1b8b27 --- /dev/null +++ b/Govor.Core/Models/Users/Crypto/SignedPreKey.cs @@ -0,0 +1,10 @@ +namespace Govor.Core.Models.Users.Crypto; + +public class SignedPreKey +{ + public Guid Id { get; set; } + public Guid UserCryptoSessionId { get; set; } + public UserCryptoSession UserCryptoSession { get; set; } + public byte[] PublicSignedPreKey { get; set; } + public byte[] SignedPreKeySignature { get; set; } +} \ No newline at end of file diff --git a/Govor.Core/Models/Users/Crypto/UserCryptoSession.cs b/Govor.Core/Models/Users/Crypto/UserCryptoSession.cs new file mode 100644 index 0000000..41403e6 --- /dev/null +++ b/Govor.Core/Models/Users/Crypto/UserCryptoSession.cs @@ -0,0 +1,14 @@ +namespace Govor.Core.Models.Users.Crypto; + +public class UserCryptoSession +{ + public Guid Id { get; set; } + + public Guid UserSessionId { get; set; } + public UserSession UserSession { get; set; } + + public byte[] PublicIdentityKey { get; set; } + + public SignedPreKey SignedPreKey { get; set; } + public ICollection OneTimePreKeys { get; set; } +} diff --git a/Govor.Core/Models/Users/UserSession.cs b/Govor.Core/Models/Users/UserSession.cs index b9cf558..da4728e 100644 --- a/Govor.Core/Models/Users/UserSession.cs +++ b/Govor.Core/Models/Users/UserSession.cs @@ -1,3 +1,5 @@ +using Govor.Core.Models.Users.Crypto; + namespace Govor.Core.Models.Users; public class UserSession @@ -6,11 +8,11 @@ public class UserSession public Guid UserId { get; set; } public string RefreshToken { get; set; } = string.Empty; public string DeviceInfo { get; set; } = string.Empty; // "Chrome on Windows" - public string PublicEncryptionKey { get; set; } - public string PublicSigningKey { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime ExpiresAt { get; set; } public bool IsRevoked { get; set; } = false; + + public UserCryptoSession CryptoSession { get; set; } public override bool Equals(object? obj) { diff --git a/Govor.Data/Configurations/OneTimePreKeyConfiguration.cs b/Govor.Data/Configurations/OneTimePreKeyConfiguration.cs new file mode 100644 index 0000000..0b3c347 --- /dev/null +++ b/Govor.Data/Configurations/OneTimePreKeyConfiguration.cs @@ -0,0 +1,33 @@ +using Govor.Core.Models.Users.Crypto; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Govor.Data.Configurations; + +public class OneTimePreKeyConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + // Первичный ключ + builder.HasKey(otpk => otpk.Id); + + builder.HasOne(otpk => otpk.UserCryptoSession) + .WithMany(ucs => ucs.OneTimePreKeys) + .HasForeignKey(otpk => otpk.UserCryptoSessionId) + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder.Property(otpk => otpk.PublicKey) + .IsRequired(); + + builder.Property(otpk => otpk.IsUsed) + .IsRequired() + .HasDefaultValue(false); + + builder.Property(otpk => otpk.UploadedAt) + .IsRequired(); + + builder.HasIndex(otpk => new { otpk.UserCryptoSessionId, otpk.IsUsed }); + builder.HasIndex(otpk => otpk.UploadedAt); + } +} \ No newline at end of file diff --git a/Govor.Data/Configurations/SignedPreKeyConfiguration.cs b/Govor.Data/Configurations/SignedPreKeyConfiguration.cs new file mode 100644 index 0000000..f28e1c4 --- /dev/null +++ b/Govor.Data/Configurations/SignedPreKeyConfiguration.cs @@ -0,0 +1,27 @@ +using Govor.Core.Models.Users.Crypto; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Govor.Data.Configurations; + +public class SignedPreKeyConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(spk => spk.Id); + + builder.HasOne(spk => spk.UserCryptoSession) + .WithOne(ucs => ucs.SignedPreKey) + .HasForeignKey(spk => spk.UserCryptoSessionId) + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder.Property(spk => spk.PublicSignedPreKey) + .IsRequired(); + builder.Property(spk => spk.SignedPreKeySignature) + .IsRequired(); + + builder.HasIndex(spk => spk.UserCryptoSessionId) + .IsUnique(); + } +} \ No newline at end of file diff --git a/Govor.Data/Configurations/UserCryptoSessionConfiguration.cs b/Govor.Data/Configurations/UserCryptoSessionConfiguration.cs new file mode 100644 index 0000000..73a8d73 --- /dev/null +++ b/Govor.Data/Configurations/UserCryptoSessionConfiguration.cs @@ -0,0 +1,37 @@ +using Govor.Core.Models.Users.Crypto; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Govor.Data.Configurations; + +public class UserCryptoSessionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(ucs => ucs.Id); + + builder.HasOne(ucs => ucs.UserSession) + .WithOne(us => us.CryptoSession) + .HasForeignKey(ucs => ucs.UserSessionId) + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(ucs => ucs.SignedPreKey) + .WithOne(spk => spk.UserCryptoSession) + .HasForeignKey(spk => spk.UserCryptoSessionId) + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder.HasMany(ucs => ucs.OneTimePreKeys) + .WithOne(otpk => otpk.UserCryptoSession) + .HasForeignKey(otpk => otpk.UserCryptoSessionId) + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(ucs => ucs.UserSessionId) + .IsUnique(); + + builder.Property(ucs => ucs.PublicIdentityKey) + .IsRequired(); + } +} \ No newline at end of file diff --git a/Govor.Data/Configurations/UserSessionConfiguration.cs b/Govor.Data/Configurations/UserSessionConfiguration.cs index 0c0c297..d81b485 100644 --- a/Govor.Data/Configurations/UserSessionConfiguration.cs +++ b/Govor.Data/Configurations/UserSessionConfiguration.cs @@ -1,5 +1,6 @@ using Govor.Core.Models; using Govor.Core.Models.Users; +using Govor.Core.Models.Users.Crypto; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -15,11 +16,16 @@ public class UserSessionConfiguration : IEntityTypeConfiguration .IsRequired(); builder.Property(us => us.DeviceInfo) - .HasMaxLength(200); + .HasMaxLength(256); builder.HasOne() .WithMany() .HasForeignKey(us => us.UserId) .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(e => e.CryptoSession) + .WithOne(e => e.UserSession) + .HasForeignKey(e => e.UserSessionId) + .OnDelete(DeleteBehavior.Cascade); } } \ No newline at end of file diff --git a/Govor.Data/GovorDbContext.cs b/Govor.Data/GovorDbContext.cs index 47ce2fc..1593fe2 100644 --- a/Govor.Data/GovorDbContext.cs +++ b/Govor.Data/GovorDbContext.cs @@ -1,6 +1,7 @@ using Govor.Core.Models; using Govor.Core.Models.Messages; using Govor.Core.Models.Users; +using Govor.Core.Models.Users.Crypto; using Govor.Data.Configurations; using Microsoft.EntityFrameworkCore; @@ -10,6 +11,9 @@ public class GovorDbContext(DbContextOptions options) : DbContex { public virtual DbSet Users { get; set; } public virtual DbSet UserSessions { get; set; } + public virtual DbSet UserCryptoSessions { get; set; } + public virtual DbSet SignedPreKeys { get; set; } + public virtual DbSet OneTimePreKeys { get; set; } public virtual DbSet Friendships { get; set; } public virtual DbSet PrivateChats { get; set; } public virtual DbSet Admins { get; set; } @@ -43,7 +47,10 @@ public class GovorDbContext(DbContextOptions options) : DbContex modelBuilder.ApplyConfiguration(new GroupInvitationConfiguration()); modelBuilder.ApplyConfiguration(new GroupMembershipConfiguration()); modelBuilder.ApplyConfiguration(new GroupAdminsConfiguration()); - + + modelBuilder.ApplyConfiguration(new OneTimePreKeyConfiguration()); + modelBuilder.ApplyConfiguration(new UserCryptoSessionConfiguration()); + modelBuilder.ApplyConfiguration(new SignedPreKeyConfiguration()); base.OnModelCreating(modelBuilder); } } \ No newline at end of file diff --git a/Govor.Data/Migrations/20250730142221_CryptKeys.Designer.cs b/Govor.Data/Migrations/20250730142221_CryptKeys.Designer.cs new file mode 100644 index 0000000..e2a9184 --- /dev/null +++ b/Govor.Data/Migrations/20250730142221_CryptKeys.Designer.cs @@ -0,0 +1,763 @@ +// +using System; +using Govor.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Govor.Data.Migrations +{ + [DbContext(typeof(GovorDbContext))] + [Migration("20250730142221_CryptKeys")] + partial class CryptKeys + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("Govor.Core.Models.ChatGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ImageId") + .HasColumnType("char(36)"); + + b.Property("IsChannel") + .HasColumnType("tinyint(1)"); + + b.Property("IsPrivate") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("Id"); + + b.ToTable("ChatGroups"); + }); + + modelBuilder.Entity("Govor.Core.Models.Friendship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AddresseeId") + .HasColumnType("char(36)"); + + b.Property("RequesterId") + .HasColumnType("char(36)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AddresseeId"); + + b.HasIndex("RequesterId"); + + b.ToTable("Friendships"); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupAdmins", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupAdmins"); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("EndDate") + .HasColumnType("datetime(6)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("InvitationCode") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("MaxParticipants") + .HasColumnType("int"); + + b.Property("UserMakerId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("UserMakerId"); + + b.ToTable("GroupInvitations"); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupMembership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("InvitationId") + .HasColumnType("char(36)"); + + b.Property("IsBanned") + .HasColumnType("tinyint(1)"); + + b.Property("MemberSince") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("InvitationId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("Govor.Core.Models.Invitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("DateCreated") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("EndDate") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("IsAdmin") + .HasColumnType("tinyint(1)"); + + b.Property("MaxParticipants") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Invitations"); + }); + + modelBuilder.Entity("Govor.Core.Models.MediaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("DateCreated") + .HasColumnType("datetime(6)"); + + b.Property("MediaType") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("MineType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("UploaderId") + .HasColumnType("char(36)"); + + b.Property("Url") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("MediaFiles"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.MediaAttachments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("MediaFileId") + .HasColumnType("char(36)"); + + b.Property("MessageId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("MediaFileId"); + + b.HasIndex("MessageId"); + + b.ToTable("MediaAttachments"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ChatGroupId") + .HasColumnType("char(36)"); + + b.Property("EditedAt") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedContent") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsEdited") + .HasColumnType("tinyint(1)"); + + b.Property("PrivateChatId") + .HasColumnType("char(36)"); + + b.Property("RecipientId") + .HasColumnType("char(36)"); + + b.Property("RecipientType") + .HasColumnType("int"); + + b.Property("ReplyToMessageId") + .HasColumnType("char(36)"); + + b.Property("SenderId") + .HasColumnType("char(36)"); + + b.Property("SentAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ChatGroupId"); + + b.HasIndex("PrivateChatId"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.MessageReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("MessageId") + .HasColumnType("char(36)"); + + b.Property("ReactedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReactionCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("MessageId", "UserId") + .IsUnique(); + + b.ToTable("MessageReactions"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.MessageView", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("MessageId") + .HasColumnType("char(36)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("ViewedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("MessageId", "UserId") + .IsUnique(); + + b.ToTable("MessageViews"); + }); + + modelBuilder.Entity("Govor.Core.Models.PrivateChat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("UserAId") + .HasColumnType("char(36)"); + + b.Property("UserBId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.ToTable("PrivateChats"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.Admin", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("UserId"); + + b.ToTable("Admins"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.Crypto.OneTimePreKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("IsUsed") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("longblob"); + + b.Property("UploadedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserCryptoSessionId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UploadedAt"); + + b.HasIndex("UserCryptoSessionId", "IsUsed"); + + b.ToTable("OneTimePreKeys"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.Crypto.SignedPreKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("PublicSignedPreKey") + .IsRequired() + .HasColumnType("longblob"); + + b.Property("SignedPreKeySignature") + .IsRequired() + .HasColumnType("longblob"); + + b.Property("UserCryptoSessionId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserCryptoSessionId") + .IsUnique(); + + b.ToTable("SignedPreKeys"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.Crypto.UserCryptoSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("PublicIdentityKey") + .IsRequired() + .HasColumnType("longblob"); + + b.Property("UserSessionId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserSessionId") + .IsUnique(); + + b.ToTable("UserCryptoSessions"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedOn") + .HasColumnType("date"); + + b.Property("Description") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IconId") + .HasColumnType("char(36)"); + + b.Property("InviteId") + .HasColumnType("char(36)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Username") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("WasOnline") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("InviteId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DeviceInfo") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime(6)"); + + b.Property("IsRevoked") + .HasColumnType("tinyint(1)"); + + b.Property("RefreshToken") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("Govor.Core.Models.Friendship", b => + { + b.HasOne("Govor.Core.Models.Users.User", "Addressee") + .WithMany("ReceivedFriendRequests") + .HasForeignKey("AddresseeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Govor.Core.Models.Users.User", "Requester") + .WithMany("SentFriendRequests") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Addressee"); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupAdmins", b => + { + b.HasOne("Govor.Core.Models.ChatGroup", null) + .WithMany("Admins") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupInvitation", b => + { + b.HasOne("Govor.Core.Models.ChatGroup", null) + .WithMany("InviteCodes") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Govor.Core.Models.Users.User", "UserMaker") + .WithMany() + .HasForeignKey("UserMakerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("UserMaker"); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupMembership", b => + { + b.HasOne("Govor.Core.Models.ChatGroup", null) + .WithMany("Members") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Govor.Core.Models.GroupInvitation", null) + .WithMany() + .HasForeignKey("InvitationId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.MediaAttachments", b => + { + b.HasOne("Govor.Core.Models.MediaFile", "MediaFile") + .WithMany() + .HasForeignKey("MediaFileId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Govor.Core.Models.Messages.Message", "Message") + .WithMany("MediaAttachments") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MediaFile"); + + b.Navigation("Message"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.Message", b => + { + b.HasOne("Govor.Core.Models.ChatGroup", null) + .WithMany("Messages") + .HasForeignKey("ChatGroupId"); + + b.HasOne("Govor.Core.Models.PrivateChat", null) + .WithMany("Messages") + .HasForeignKey("PrivateChatId"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.MessageReaction", b => + { + b.HasOne("Govor.Core.Models.Messages.Message", "Message") + .WithMany("Reactions") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Govor.Core.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Message"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.MessageView", b => + { + b.HasOne("Govor.Core.Models.Messages.Message", null) + .WithMany("MessageViews") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.Admin", b => + { + b.HasOne("Govor.Core.Models.Users.User", "User") + .WithOne() + .HasForeignKey("Govor.Core.Models.Users.Admin", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.Crypto.OneTimePreKey", b => + { + b.HasOne("Govor.Core.Models.Users.Crypto.UserCryptoSession", "UserCryptoSession") + .WithMany("OneTimePreKeys") + .HasForeignKey("UserCryptoSessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserCryptoSession"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.Crypto.SignedPreKey", b => + { + b.HasOne("Govor.Core.Models.Users.Crypto.UserCryptoSession", "UserCryptoSession") + .WithOne("SignedPreKey") + .HasForeignKey("Govor.Core.Models.Users.Crypto.SignedPreKey", "UserCryptoSessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserCryptoSession"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.Crypto.UserCryptoSession", b => + { + b.HasOne("Govor.Core.Models.Users.UserSession", "UserSession") + .WithOne("CryptoSession") + .HasForeignKey("Govor.Core.Models.Users.Crypto.UserCryptoSession", "UserSessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserSession"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.User", b => + { + b.HasOne("Govor.Core.Models.Invitation", "Invite") + .WithMany("Users") + .HasForeignKey("InviteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Invite"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.UserSession", b => + { + b.HasOne("Govor.Core.Models.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Govor.Core.Models.ChatGroup", b => + { + b.Navigation("Admins"); + + b.Navigation("InviteCodes"); + + b.Navigation("Members"); + + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Govor.Core.Models.Invitation", b => + { + b.Navigation("Users"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.Message", b => + { + b.Navigation("MediaAttachments"); + + b.Navigation("MessageViews"); + + b.Navigation("Reactions"); + }); + + modelBuilder.Entity("Govor.Core.Models.PrivateChat", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.Crypto.UserCryptoSession", b => + { + b.Navigation("OneTimePreKeys"); + + b.Navigation("SignedPreKey") + .IsRequired(); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.User", b => + { + b.Navigation("ReceivedFriendRequests"); + + b.Navigation("SentFriendRequests"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.UserSession", b => + { + b.Navigation("CryptoSession") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Govor.Data/Migrations/20250730142221_CryptKeys.cs b/Govor.Data/Migrations/20250730142221_CryptKeys.cs new file mode 100644 index 0000000..035402f --- /dev/null +++ b/Govor.Data/Migrations/20250730142221_CryptKeys.cs @@ -0,0 +1,170 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Govor.Data.Migrations +{ + /// + public partial class CryptKeys : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "DeviceInfo", + table: "UserSessions", + type: "varchar(256)", + maxLength: 256, + nullable: false, + oldClrType: typeof(string), + oldType: "varchar(200)", + oldMaxLength: 200) + .Annotation("MySql:CharSet", "utf8mb4") + .OldAnnotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AlterColumn( + name: "InvitationId", + table: "GroupMemberships", + type: "char(36)", + nullable: true, + collation: "ascii_general_ci", + oldClrType: typeof(Guid), + oldType: "char(36)") + .OldAnnotation("Relational:Collation", "ascii_general_ci"); + + migrationBuilder.AddColumn( + name: "MemberSince", + table: "GroupMemberships", + type: "datetime(6)", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.CreateTable( + name: "UserCryptoSessions", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + UserSessionId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + PublicIdentityKey = table.Column(type: "longblob", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserCryptoSessions", x => x.Id); + table.ForeignKey( + name: "FK_UserCryptoSessions_UserSessions_UserSessionId", + column: x => x.UserSessionId, + principalTable: "UserSessions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "OneTimePreKeys", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + UserCryptoSessionId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + PublicKey = table.Column(type: "longblob", nullable: false), + IsUsed = table.Column(type: "tinyint(1)", nullable: false, defaultValue: false), + UploadedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_OneTimePreKeys", x => x.Id); + table.ForeignKey( + name: "FK_OneTimePreKeys_UserCryptoSessions_UserCryptoSessionId", + column: x => x.UserCryptoSessionId, + principalTable: "UserCryptoSessions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "SignedPreKeys", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + UserCryptoSessionId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + PublicSignedPreKey = table.Column(type: "longblob", nullable: false), + SignedPreKeySignature = table.Column(type: "longblob", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SignedPreKeys", x => x.Id); + table.ForeignKey( + name: "FK_SignedPreKeys_UserCryptoSessions_UserCryptoSessionId", + column: x => x.UserCryptoSessionId, + principalTable: "UserCryptoSessions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_OneTimePreKeys_UploadedAt", + table: "OneTimePreKeys", + column: "UploadedAt"); + + migrationBuilder.CreateIndex( + name: "IX_OneTimePreKeys_UserCryptoSessionId_IsUsed", + table: "OneTimePreKeys", + columns: new[] { "UserCryptoSessionId", "IsUsed" }); + + migrationBuilder.CreateIndex( + name: "IX_SignedPreKeys_UserCryptoSessionId", + table: "SignedPreKeys", + column: "UserCryptoSessionId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UserCryptoSessions_UserSessionId", + table: "UserCryptoSessions", + column: "UserSessionId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "OneTimePreKeys"); + + migrationBuilder.DropTable( + name: "SignedPreKeys"); + + migrationBuilder.DropTable( + name: "UserCryptoSessions"); + + migrationBuilder.DropColumn( + name: "MemberSince", + table: "GroupMemberships"); + + migrationBuilder.AlterColumn( + name: "DeviceInfo", + table: "UserSessions", + type: "varchar(200)", + maxLength: 200, + nullable: false, + oldClrType: typeof(string), + oldType: "varchar(256)", + oldMaxLength: 256) + .Annotation("MySql:CharSet", "utf8mb4") + .OldAnnotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AlterColumn( + name: "InvitationId", + table: "GroupMemberships", + type: "char(36)", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), + collation: "ascii_general_ci", + oldClrType: typeof(Guid), + oldType: "char(36)", + oldNullable: true) + .OldAnnotation("Relational:Collation", "ascii_general_ci"); + } + } +} diff --git a/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs b/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs index 49f1d34..084ac34 100644 --- a/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs +++ b/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs @@ -145,12 +145,14 @@ namespace Govor.Data.Migrations .HasColumnType("char(36)"); b.Property("InvitationId") - .IsRequired() .HasColumnType("char(36)"); b.Property("IsBanned") .HasColumnType("tinyint(1)"); + b.Property("MemberSince") + .HasColumnType("datetime(6)"); + b.Property("UserId") .HasColumnType("char(36)"); @@ -364,40 +366,6 @@ namespace Govor.Data.Migrations b.ToTable("PrivateChats"); }); - modelBuilder.Entity("Govor.Core.Models.UserSession", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("char(36)"); - - b.Property("CreatedAt") - .HasColumnType("datetime(6)"); - - b.Property("DeviceInfo") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("varchar(200)"); - - b.Property("ExpiresAt") - .HasColumnType("datetime(6)"); - - b.Property("IsRevoked") - .HasColumnType("tinyint(1)"); - - b.Property("RefreshToken") - .IsRequired() - .HasColumnType("longtext"); - - b.Property("UserId") - .HasColumnType("char(36)"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("UserSessions"); - }); - modelBuilder.Entity("Govor.Core.Models.Users.Admin", b => { b.Property("UserId") @@ -408,6 +376,82 @@ namespace Govor.Data.Migrations b.ToTable("Admins"); }); + modelBuilder.Entity("Govor.Core.Models.Users.Crypto.OneTimePreKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("IsUsed") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("longblob"); + + b.Property("UploadedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserCryptoSessionId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UploadedAt"); + + b.HasIndex("UserCryptoSessionId", "IsUsed"); + + b.ToTable("OneTimePreKeys"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.Crypto.SignedPreKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("PublicSignedPreKey") + .IsRequired() + .HasColumnType("longblob"); + + b.Property("SignedPreKeySignature") + .IsRequired() + .HasColumnType("longblob"); + + b.Property("UserCryptoSessionId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserCryptoSessionId") + .IsUnique(); + + b.ToTable("SignedPreKeys"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.Crypto.UserCryptoSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("PublicIdentityKey") + .IsRequired() + .HasColumnType("longblob"); + + b.Property("UserSessionId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserSessionId") + .IsUnique(); + + b.ToTable("UserCryptoSessions"); + }); + modelBuilder.Entity("Govor.Core.Models.Users.User", b => { b.Property("Id") @@ -445,6 +489,40 @@ namespace Govor.Data.Migrations b.ToTable("Users"); }); + modelBuilder.Entity("Govor.Core.Models.Users.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DeviceInfo") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime(6)"); + + b.Property("IsRevoked") + .HasColumnType("tinyint(1)"); + + b.Property("RefreshToken") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserSessions"); + }); + modelBuilder.Entity("Govor.Core.Models.Friendship", b => { b.HasOne("Govor.Core.Models.Users.User", "Addressee") @@ -501,8 +579,7 @@ namespace Govor.Data.Migrations b.HasOne("Govor.Core.Models.GroupInvitation", null) .WithMany() .HasForeignKey("InvitationId") - .OnDelete(DeleteBehavior.SetNull) - .IsRequired(); + .OnDelete(DeleteBehavior.SetNull); }); modelBuilder.Entity("Govor.Core.Models.Messages.MediaAttachments", b => @@ -563,15 +640,6 @@ namespace Govor.Data.Migrations .IsRequired(); }); - modelBuilder.Entity("Govor.Core.Models.UserSession", b => - { - b.HasOne("Govor.Core.Models.Users.User", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - modelBuilder.Entity("Govor.Core.Models.Users.Admin", b => { b.HasOne("Govor.Core.Models.Users.User", "User") @@ -583,6 +651,39 @@ namespace Govor.Data.Migrations b.Navigation("User"); }); + modelBuilder.Entity("Govor.Core.Models.Users.Crypto.OneTimePreKey", b => + { + b.HasOne("Govor.Core.Models.Users.Crypto.UserCryptoSession", "UserCryptoSession") + .WithMany("OneTimePreKeys") + .HasForeignKey("UserCryptoSessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserCryptoSession"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.Crypto.SignedPreKey", b => + { + b.HasOne("Govor.Core.Models.Users.Crypto.UserCryptoSession", "UserCryptoSession") + .WithOne("SignedPreKey") + .HasForeignKey("Govor.Core.Models.Users.Crypto.SignedPreKey", "UserCryptoSessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserCryptoSession"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.Crypto.UserCryptoSession", b => + { + b.HasOne("Govor.Core.Models.Users.UserSession", "UserSession") + .WithOne("CryptoSession") + .HasForeignKey("Govor.Core.Models.Users.Crypto.UserCryptoSession", "UserSessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserSession"); + }); + modelBuilder.Entity("Govor.Core.Models.Users.User", b => { b.HasOne("Govor.Core.Models.Invitation", "Invite") @@ -594,6 +695,15 @@ namespace Govor.Data.Migrations b.Navigation("Invite"); }); + modelBuilder.Entity("Govor.Core.Models.Users.UserSession", b => + { + b.HasOne("Govor.Core.Models.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Govor.Core.Models.ChatGroup", b => { b.Navigation("Admins"); @@ -624,12 +734,26 @@ namespace Govor.Data.Migrations b.Navigation("Messages"); }); + modelBuilder.Entity("Govor.Core.Models.Users.Crypto.UserCryptoSession", b => + { + b.Navigation("OneTimePreKeys"); + + b.Navigation("SignedPreKey") + .IsRequired(); + }); + modelBuilder.Entity("Govor.Core.Models.Users.User", b => { b.Navigation("ReceivedFriendRequests"); b.Navigation("SentFriendRequests"); }); + + modelBuilder.Entity("Govor.Core.Models.Users.UserSession", b => + { + b.Navigation("CryptoSession") + .IsRequired(); + }); #pragma warning restore 612, 618 } } From 6d4d269d7458324cf6ef10ea73d47e7edb58a512 Mon Sep 17 00:00:00 2001 From: stalcke2288969 Date: Fri, 31 Oct 2025 09:17:16 +0000 Subject: [PATCH 084/101] GITBOOK-10: No subject --- docs/README.md | 48 ++++++++++++++++++++++++++++- docs/SUMMARY.md | 32 +++++++++---------- docs/endpoints/sessioncontroller.md | 33 +++++++++++++++++++- 3 files changed, 95 insertions(+), 18 deletions(-) diff --git a/docs/README.md b/docs/README.md index 5c4b4d5..482e6b1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,2 +1,48 @@ -# Page +--- +icon: face-confounded +coverY: 0 +--- +# The invitation code RU + +### Что это такое + +**Код приглашения** — это временный механизм доступа, действующий на этапах **альфа-** и **бета-тестирования** мессенджера **Говор**.\ +Он используется для **ограничения количества аккаунтов** и **контроля числа пользователей**, получающих ранний доступ к продукту. + +Пока Говор находится в стадии активной разработки и внутреннего тестирования, регистрация новых пользователей возможна **только при наличии действующего кода приглашения**. + +### Как получить код приглашения + +Коды приглашения **создаются администрацией проекта** и **выдаются вручную** по мере готовности новых тестовых слотов. + +Чтобы запросить код приглашения: + +1. Свяжитесь с командой разработчиков. +2. Кратко опишите, зачем вы хотите протестировать Говор. +3. Дождитесь ответа с вашим уникальным кодом. + +📩 **Контакт для запроса:**\ +`BurntRoostersOfficial@protonmail.com` + +### Как выглядит код + +Код приглашения представляет собой **уникальный случайно сгенерированный идентификатор** (объект `Guid`).\ +Пример: + +``` +c7a5f1b3-8c64-47e2-9e3e-b7df6a2b021f +``` + +### Ограничения + +Каждый код приглашения имеет: + +* 🔹 **Ограниченное количество регистраций.**\ + После достижения лимита использование кода становится невозможным. +* ⏳ **Ограниченный срок действия.**\ + По истечении времени жизни код автоматически теряет актуальность. + +### После официального релиза + +После выхода **первой стабильной версии Говора** система кодов приглашений будет **отключена**, и регистрация станет **открытой для всех пользователей**. diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 8885cc7..1403c18 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -1,29 +1,29 @@ # Table of contents -* [Page](README.md) +* [The invitation code RU](README.md) ## Endpoints -* [Authentication](../docs/endpoints/authentication/README.md) - * [AuthController](../docs/endpoints/authentication/authcontroller.md) - * [RefreshController](../docs/endpoints/authentication/refreshcontroller.md) -* [SessionController](../docs/endpoints/sessioncontroller.md) -* [FriendshipController](../docs/endpoints/friendshipcontroller.md) -* [FriendsRequestQueryController](../docs/endpoints/friendsrequestquerycontroller.md) -* [MediaController](../docs/endpoints/mediacontroller.md) -* [ChatLoadController](../docs/endpoints/chatloadcontroller.md) -* [OnlinePingingController(не работает)](../docs/endpoints/onlinepingingcontroller-ne-rabotaet.md) +* [Authentication](endpoints/authentication/README.md) + * [AuthController](endpoints/authentication/authcontroller.md) + * [RefreshController](endpoints/authentication/refreshcontroller.md) +* [SessionController](endpoints/sessioncontroller.md) +* [FriendshipController](endpoints/friendshipcontroller.md) +* [FriendsRequestQueryController](endpoints/friendsrequestquerycontroller.md) +* [MediaController](endpoints/mediacontroller.md) +* [ChatLoadController](endpoints/chatloadcontroller.md) +* [OnlinePingingController(не работает)](endpoints/onlinepingingcontroller-ne-rabotaet.md) ## SignalR -* [PresenceHub](../docs/signalr/presencehub.md) -* [ChatHub](../docs/signalr/chathub.md) +* [PresenceHub](signalr/presencehub.md) +* [ChatHub](signalr/chathub.md) *** -* [FriendsHub](../docs/friendshub/README.md) - * [FriendsHub Client (Java)](../docs/friendshub/friendshub-client-java.md) +* [FriendsHub](friendshub/README.md) + * [FriendsHub Client (Java)](friendshub/friendshub-client-java.md) -## Code Docs +## Code Docs -* [HubResult\](../docs/code-docs/hubresult-less-than-t-greater-than.md) +* [HubResult\](code-docs/hubresult-less-than-t-greater-than.md) diff --git a/docs/endpoints/sessioncontroller.md b/docs/endpoints/sessioncontroller.md index afb2b05..abdc3b8 100644 --- a/docs/endpoints/sessioncontroller.md +++ b/docs/endpoints/sessioncontroller.md @@ -75,6 +75,37 @@ description: >- "Unexpected Error! Please try again later." ``` +#### CloseSession + +* **Description**: Closes a current session for the authenticated user. +* **Route**: `[DELETE] api/session/close/` +* **HTTP Method**: `DELETE` +* **Request**: + * No body. +* **Responses**: + * **200 OK**: Indicates session was successfully closed. + * No body. + * **400 Bad Request**: If `sessionId` is invalid or operation fails. + + ```json + "string" + ``` + * **403 Forbidden**: If user lacks authorization. + + ```json + "string" + ``` + * **404 Not Found**: If session is not found. + + ```json + "string" + ``` + * **500 Internal Server Error**: Indicates an unexpected error. + + ```json + "Unexpected Error! Please try again later." + ``` + #### CloseAllSessions * **Description**: Closes all active sessions for the authenticated user. @@ -89,7 +120,7 @@ description: >- ```json "string" ``` - * **500 Internal Server Error**: Indicates an unexpected error. + * **500 Internal Server Error**: Indicates an unexpected error. ```json "Unexpected Error! Please try again later." From a5a5fc4758d23c8cd843cfa27aaf1c4219e366ab Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Tue, 4 Nov 2025 11:41:06 +0700 Subject: [PATCH 085/101] new app server and database + added hub and controller of user profile --- .../Controllers/SessionControllerTests.cs | 106 +++- Govor.API.Tests/Govor.API.Tests.csproj | 2 + .../Controllers/ProfileControllerTests.cs | 145 +++++ .../ConfigurationProgramExtensions.cs | 2 + Govor.API/Common/Mapping/MappingProfile.cs | 3 + .../AdminStuff/InviteUserController.cs | 2 +- Govor.API/Controllers/MediaController.cs | 14 +- Govor.API/Controllers/ProfileController.cs | 67 ++ Govor.API/Controllers/SessionController.cs | 38 +- Govor.API/Hubs/ChatsHub.cs | 15 +- Govor.API/Hubs/ProfileHub.cs | 140 ++++ Govor.API/Program.cs | 3 +- Govor.API/appsettings.json | 8 +- ...76a3b6-8b2c-435b-b253-5e6af7867b8e_Гол.png | 0 ...352c29-be8e-406d-a75c-321988cb8097_Гол.png | 0 ...67cf9-66b0-48b2-b681-6f0d1ecc3559_Имба.png | Bin 0 -> 707658 bytes ...5ec0726bbdb2_photo_2025-10-22_23-02-17.jpg | Bin 0 -> 141115 bytes ...60bac0c819bf_photo_2025-10-23_19-45-47.jpg | Bin 0 -> 322058 bytes ...e56dbc0d70a9_photo_2025-10-22_23-02-17.jpg | Bin 0 -> 141115 bytes ...9058-3e2f-410f-b010-f91747f8091c_Быдло.png | Bin 0 -> 491532 bytes ...7dd1fcc7652e_photo_2025-10-23_19-45-47.jpg | 0 .../Govor.Application.Tests.csproj | 2 + .../AccesserToDownloadMediaServiceTests.cs | 152 ++++- .../Messages/MessageCommandServiceTests.cs | 56 +- .../Services/ProfileServiceTests.cs | 131 ++++ .../UserSessions/UserSessionReaderTests.cs | 7 +- .../Validators/UsernameValidator.cs | 1 - .../Interfaces/IProfileService.cs | 10 + .../Interfaces/Medias/IMediaService.cs | 6 +- Govor.Application/Profiles/UserProfile.cs | 9 + .../Services/Authentication/AuthService.cs | 2 +- .../Services/LocalStorageService.cs | 2 +- .../Medias/AccesserToDownloadMediaService.cs | 45 +- .../Services/Medias/MediaService.cs | 28 +- .../Messages/MessageCommandService.cs | 17 +- Govor.Application/Services/ProfileService.cs | 48 ++ .../Services/VerifierFriendship.cs | 8 +- Govor.Contracts/DTOs/UserProfileDto.cs | 9 + .../Requests/MediaUploadRequest.cs | 5 +- .../Requests/SignalR/MessageRequest.cs | 3 + Govor.Core/Models/MediaFile.cs | 11 + Govor.Core/Models/Users/UserSession.cs | 3 +- .../20250707142430_InitialCreate.Designer.cs | 582 ----------------- ...090733_AddChatGroupIdToMessage.Designer.cs | 593 ----------------- .../20250712090733_AddChatGroupIdToMessage.cs | 50 -- ...01943_AddUploaderIdToMediaFile.Designer.cs | 596 ------------------ .../Migrations/20250718130008_usersessions.cs | 53 -- .../Migrations/20250730142221_CryptKeys.cs | 170 ----- ... 20251019125424_InitialCreate.Designer.cs} | 198 +++--- ...ate.cs => 20251019125424_InitialCreate.cs} | 342 ++++++---- ...103060801_MediaOwnerTypeAdded.Designer.cs} | 380 +++++++---- ... => 20251103060801_MediaOwnerTypeAdded.cs} | 21 +- .../Migrations/GovorDbContextModelSnapshot.cs | 200 +++--- Govor.Data/Repositories/AdminsRepository.cs | 2 + .../Repositories/FriendshipsRepository.cs | 2 + Govor.Data/Repositories/GroupRepository.cs | 2 + Govor.Data/Repositories/InvitesRepository.cs | 2 + .../MediaAttachmentsRepository.cs | 2 + Govor.Data/Repositories/MessagesRepository.cs | 2 + .../Repositories/PrivateChatsRepository.cs | 2 + .../Repositories/UserSessionsRepository.cs | 2 + Govor.Data/Repositories/UsersRepository.cs | 2 + 62 files changed, 1760 insertions(+), 2543 deletions(-) create mode 100644 Govor.API.Tests/IntegrationTests/Controllers/ProfileControllerTests.cs create mode 100644 Govor.API/Controllers/ProfileController.cs create mode 100644 Govor.API/Hubs/ProfileHub.cs create mode 100644 Govor.API/uploads/2025.11/1076a3b6-8b2c-435b-b253-5e6af7867b8e_Гол.png create mode 100644 Govor.API/uploads/2025.11/13352c29-be8e-406d-a75c-321988cb8097_Гол.png create mode 100644 Govor.API/uploads/2025.11/33567cf9-66b0-48b2-b681-6f0d1ecc3559_Имба.png create mode 100644 Govor.API/uploads/2025.11/46f20a02-3660-4f01-864c-5ec0726bbdb2_photo_2025-10-22_23-02-17.jpg create mode 100644 Govor.API/uploads/2025.11/8ada5627-87d3-4fec-9df0-60bac0c819bf_photo_2025-10-23_19-45-47.jpg create mode 100644 Govor.API/uploads/2025.11/b778c08a-1944-4689-9731-e56dbc0d70a9_photo_2025-10-22_23-02-17.jpg create mode 100644 Govor.API/uploads/2025.11/d1049058-3e2f-410f-b010-f91747f8091c_Быдло.png create mode 100644 Govor.API/uploads/2025.11/fe755844-938b-4b86-9bb7-7dd1fcc7652e_photo_2025-10-23_19-45-47.jpg create mode 100644 Govor.Application.Tests/Services/ProfileServiceTests.cs create mode 100644 Govor.Application/Interfaces/IProfileService.cs create mode 100644 Govor.Application/Profiles/UserProfile.cs create mode 100644 Govor.Application/Services/ProfileService.cs create mode 100644 Govor.Contracts/DTOs/UserProfileDto.cs delete mode 100644 Govor.Data/Migrations/20250707142430_InitialCreate.Designer.cs delete mode 100644 Govor.Data/Migrations/20250712090733_AddChatGroupIdToMessage.Designer.cs delete mode 100644 Govor.Data/Migrations/20250712090733_AddChatGroupIdToMessage.cs delete mode 100644 Govor.Data/Migrations/20250713101943_AddUploaderIdToMediaFile.Designer.cs delete mode 100644 Govor.Data/Migrations/20250718130008_usersessions.cs delete mode 100644 Govor.Data/Migrations/20250730142221_CryptKeys.cs rename Govor.Data/Migrations/{20250730142221_CryptKeys.Designer.cs => 20251019125424_InitialCreate.Designer.cs} (80%) rename Govor.Data/Migrations/{20250707142430_InitialCreate.cs => 20251019125424_InitialCreate.cs} (51%) rename Govor.Data/Migrations/{20250718130008_usersessions.Designer.cs => 20251103060801_MediaOwnerTypeAdded.Designer.cs} (64%) rename Govor.Data/Migrations/{20250713101943_AddUploaderIdToMediaFile.cs => 20251103060801_MediaOwnerTypeAdded.cs} (54%) diff --git a/Govor.API.Tests/Controllers/SessionControllerTests.cs b/Govor.API.Tests/Controllers/SessionControllerTests.cs index b8add34..0d1f2ff 100644 --- a/Govor.API.Tests/Controllers/SessionControllerTests.cs +++ b/Govor.API.Tests/Controllers/SessionControllerTests.cs @@ -3,7 +3,6 @@ using Govor.API.Controllers; using Govor.Application.Interfaces.Infrastructure.Extensions; using Govor.Application.Interfaces.UserSession; using Govor.Contracts.DTOs; -using Govor.Core.Models; using Govor.Core.Models.Users; using Govor.Data.Repositories.Exceptions; using Microsoft.AspNetCore.Mvc; @@ -18,6 +17,7 @@ public class SessionControllerTests { private Mock> _loggerMock; private Mock _currentUserServiceMock; + private Mock _currentUserSessionServiceMock; private Mock _userSessionReaderMock; private Mock _userSessionRevokerMock; private Mock _mapperMock; @@ -28,17 +28,20 @@ public class SessionControllerTests { _loggerMock = new Mock>(); _currentUserServiceMock = new Mock(); + _currentUserSessionServiceMock = new Mock(); _userSessionReaderMock = new Mock(); _userSessionRevokerMock = new Mock(); _mapperMock = new Mock(); _controller = new SessionController( _loggerMock.Object, _currentUserServiceMock.Object, + _currentUserSessionServiceMock.Object, _userSessionReaderMock.Object, _userSessionRevokerMock.Object, _mapperMock.Object); } + #region GetAllSessions [Test] public async Task GetAllSessions_Successful_ReturnsOkWithMappedSessions() { @@ -106,7 +109,9 @@ public class SessionControllerTests exception, It.IsAny>()), Times.Once()); } + #endregion + #region CloseSession [Test] public async Task CloseSession_ValidSessionId_ReturnsOk() { @@ -213,7 +218,105 @@ public class SessionControllerTests exception, It.IsAny>()), Times.Once()); } + #endregion + #region CloseCurrentSession + [Test] + public async Task CloseCurrentSession_ValidSessionIdAndUserId_ReturnsOk() + { + // Arrange + var userId = Guid.NewGuid(); + var sessionId = Guid.NewGuid(); + _currentUserServiceMock.Setup(s => s.GetCurrentUserId()).Returns(userId); + _currentUserSessionServiceMock.Setup(s => s.GetUserSessionId()).Returns(sessionId); + + // Act + var result = await _controller.CloseCurrent(); + + // Assert + Assert.That(result, Is.TypeOf()); + _userSessionRevokerMock.Verify(r => r.CloseSessionByIdAsync(sessionId, userId), Times.Once()); + _loggerMock.VerifyNoOtherCalls(); + } + + [Test] + public async Task CloseCurrentSession_UnauthorizedAccess_ReturnsForbid() + { + // Arrange + var userId = Guid.NewGuid(); + var sessionId = Guid.NewGuid(); + var exception = new UnauthorizedAccessException("Unauthorized"); + _currentUserServiceMock.Setup(s => s.GetCurrentUserId()).Returns(userId); + _currentUserSessionServiceMock.Setup(s => s.GetUserSessionId()).Returns(sessionId); + _userSessionRevokerMock.Setup(r => r.CloseSessionByIdAsync(sessionId, userId)).ThrowsAsync(exception); + + // Act + var result = await _controller.CloseCurrent(); + + // Assert + Assert.That(result, Is.TypeOf()); + _loggerMock.Verify(l => l.Log( + LogLevel.Warning, + It.IsAny(), + It.IsAny(), + exception, + It.IsAny>()), Times.Once()); + } + + [Test] + public async Task CloseCurrentSession_NotFound_ReturnsNotFound() + { + // Arrange + var userId = Guid.NewGuid(); + var sessionId = Guid.NewGuid(); + var exception = new NotFoundException("Session not found"); + _currentUserServiceMock.Setup(s => s.GetCurrentUserId()).Returns(userId); + _currentUserSessionServiceMock.Setup(s => s.GetUserSessionId()).Returns(sessionId); + _userSessionRevokerMock.Setup(r => r.CloseSessionByIdAsync(sessionId, userId)).ThrowsAsync(exception); + + // Act + var result = await _controller.CloseCurrent(); + + // Assert + Assert.That(result, Is.TypeOf()); + var notFoundResult = result as NotFoundObjectResult; + Assert.That(notFoundResult.Value, Is.EqualTo("Session not found")); + _loggerMock.Verify(l => l.Log( + LogLevel.Warning, + It.IsAny(), + It.IsAny(), + exception, + It.IsAny>()), Times.Once()); + } + + [Test] + public async Task CloseCurrentSession_InvalidOperation_ReturnsBadRequest() + { + // Arrange + var userId = Guid.NewGuid(); + var sessionId = Guid.NewGuid(); + var exception = new InvalidOperationException("Invalid operation"); + _currentUserServiceMock.Setup(s => s.GetCurrentUserId()).Returns(userId); + _currentUserSessionServiceMock.Setup(s => s.GetUserSessionId()).Returns(sessionId); + _userSessionRevokerMock.Setup(r => r.CloseSessionByIdAsync(sessionId, userId)).ThrowsAsync(exception); + + // Act + var result = await _controller.CloseCurrent(); + + // Assert + Assert.That(result, Is.TypeOf()); + var badRequestResult = result as BadRequestObjectResult; + Assert.That(badRequestResult.Value, Is.EqualTo("Invalid operation")); + _loggerMock.Verify(l => l.Log( + LogLevel.Error, + It.IsAny(), + It.IsAny(), + exception, + It.IsAny>()), Times.Once()); + } + #endregion + + #region CloseAllSessions [Test] public async Task CloseAllSessions_Successful_ReturnsOk() { @@ -276,4 +379,5 @@ public class SessionControllerTests exception, It.IsAny>()), Times.Once()); } + #endregion } \ No newline at end of file diff --git a/Govor.API.Tests/Govor.API.Tests.csproj b/Govor.API.Tests/Govor.API.Tests.csproj index 2ef9646..26fdd64 100644 --- a/Govor.API.Tests/Govor.API.Tests.csproj +++ b/Govor.API.Tests/Govor.API.Tests.csproj @@ -16,6 +16,8 @@ + + diff --git a/Govor.API.Tests/IntegrationTests/Controllers/ProfileControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/ProfileControllerTests.cs new file mode 100644 index 0000000..ac561c9 --- /dev/null +++ b/Govor.API.Tests/IntegrationTests/Controllers/ProfileControllerTests.cs @@ -0,0 +1,145 @@ +using AutoMapper; +using Govor.API.Controllers; +using Govor.Application.Interfaces; +using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.Application.Interfaces.Medias; +using Govor.Application.Profiles; +using Govor.Contracts.DTOs; +using Govor.Data.Repositories.Exceptions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Govor.API.Tests; + +[TestFixture] +public class ProfileControllerTests +{ + private Mock> _mockLogger = null!; + private Mock _mockMediaService = null!; + private Mock _mockProfileService = null!; + private Mock _mockCurrentUserService = null!; + private Mock _mockMapper = null!; + private ProfileController _controller = null!; + + private Guid _userId; + + [SetUp] + public void SetUp() + { + _mockLogger = new Mock>(); + _mockMediaService = new Mock(); + _mockProfileService = new Mock(); + _mockCurrentUserService = new Mock(); + _mockMapper = new Mock(); + + _controller = new ProfileController( + _mockLogger.Object, + _mockMediaService.Object, + _mockProfileService.Object, + _mockCurrentUserService.Object, + _mockMapper.Object); + + _userId = Guid.NewGuid(); + _mockCurrentUserService.Setup(x => x.GetCurrentUserId()).Returns(_userId); + } + + [Test] + public async Task DowloadProfile_ReturnsOk_WhenProfileExists() + { + // Arrange + var userProfile = new UserProfile + { + Id = _userId, + Username = "test_user", + Description = "test description", + IconId = Guid.NewGuid() + }; + + var userProfileDto = new UserProfileDto + { + Id = _userId, + Username = "test_user", + Description = "test description", + IconId = userProfile.IconId + }; + + _mockProfileService.Setup(s => s.GetUserProfileAsync(_userId)) + .ReturnsAsync(userProfile); + + _mockMapper.Setup(m => m.Map(userProfile)) + .Returns(userProfileDto); + + // Act + var result = await _controller.DowloadProfile(); + + // Assert + var okResult = result as OkObjectResult; + Assert.That(okResult, Is.Not.Null); + Assert.That(okResult!.StatusCode, Is.EqualTo(200)); + Assert.That(okResult.Value, Is.EqualTo(userProfileDto)); + } + + [Test] + public async Task DowloadProfile_ReturnsNotFound_WhenProfileIsNull() + { + // Arrange + _mockProfileService.Setup(s => s.GetUserProfileAsync(_userId)) + .ReturnsAsync((UserProfile?)null); + + // Act + var result = await _controller.DowloadProfile(); + + // Assert + var notFoundResult = result as NotFoundObjectResult; + Assert.That(notFoundResult, Is.Not.Null); + Assert.That(notFoundResult!.Value, Is.EqualTo("Profile not found")); + } + + [Test] + public async Task DowloadProfile_ReturnsForbid_WhenUnauthorizedAccessExceptionThrown() + { + // Arrange + _mockProfileService.Setup(s => s.GetUserProfileAsync(_userId)) + .ThrowsAsync(new UnauthorizedAccessException("Access denied")); + + // Act + var result = await _controller.DowloadProfile(); + + // Assert + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task DowloadProfile_ReturnsBadRequest_WhenNotFoundExceptionThrown() + { + // Arrange + _mockProfileService.Setup(s => s.GetUserProfileAsync(_userId)) + .ThrowsAsync(new NotFoundException("User not found")); + + // Act + var result = await _controller.DowloadProfile(); + + // Assert + var badRequest = result as BadRequestObjectResult; + Assert.That(badRequest, Is.Not.Null); + Assert.That(badRequest!.Value, Is.EqualTo("User not found")); + } + + [Test] + public async Task DowloadProfile_Returns500_WhenUnexpectedExceptionThrown() + { + // Arrange + _mockProfileService.Setup(s => s.GetUserProfileAsync(_userId)) + .ThrowsAsync(new Exception("Unexpected error")); + + // Act + var result = await _controller.DowloadProfile(); + + // Assert + var objectResult = result as ObjectResult; + Assert.That(objectResult, Is.Not.Null); + Assert.That(objectResult!.StatusCode, Is.EqualTo(500)); + Assert.That(objectResult.Value, Is.EqualTo("Unexpected Error! Please try again later.")); + } +} \ No newline at end of file diff --git a/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs index c6782aa..522af81 100644 --- a/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs +++ b/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs @@ -97,6 +97,8 @@ public static class ConfigurationProgramExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + + services.AddScoped(); } public static void AddRepositories(this IServiceCollection services) diff --git a/Govor.API/Common/Mapping/MappingProfile.cs b/Govor.API/Common/Mapping/MappingProfile.cs index 9f94bda..dcf9435 100644 --- a/Govor.API/Common/Mapping/MappingProfile.cs +++ b/Govor.API/Common/Mapping/MappingProfile.cs @@ -1,5 +1,6 @@ using AutoMapper; using Govor.API.Extensions.Mapping; +using Govor.Application.Profiles; using Govor.Contracts.DTOs; using Govor.Contracts.Responses; using Govor.Core.Models; @@ -23,5 +24,7 @@ public class MappingProfile : Profile CreateMap(); CreateMap(); + + CreateMap(); } } \ No newline at end of file diff --git a/Govor.API/Controllers/AdminStuff/InviteUserController.cs b/Govor.API/Controllers/AdminStuff/InviteUserController.cs index 00121b8..204ae79 100644 --- a/Govor.API/Controllers/AdminStuff/InviteUserController.cs +++ b/Govor.API/Controllers/AdminStuff/InviteUserController.cs @@ -9,7 +9,7 @@ namespace Govor.API.Controllers.AdminStuff; [Route("api/admin/[controller]")] [ApiController] -[Authorize(Roles = "Admin")] +//[Authorize(Roles = "Admin")] public class InviteUserController : Controller { private readonly IInvitesRepository _repository; diff --git a/Govor.API/Controllers/MediaController.cs b/Govor.API/Controllers/MediaController.cs index bec6eda..6c0a870 100644 --- a/Govor.API/Controllers/MediaController.cs +++ b/Govor.API/Controllers/MediaController.cs @@ -1,6 +1,7 @@ using Govor.Application.Interfaces.Infrastructure.Extensions; using Govor.Application.Interfaces.Medias; using Govor.Contracts.Requests; +using Govor.Core.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -47,7 +48,7 @@ public class MediaController : Controller try { byte[] fileBytes = await ReadFileAsync(request.FromFile); - + var media = new Media( _currentUserService.GetCurrentUserId(), DateTime.UtcNow, @@ -55,8 +56,15 @@ public class MediaController : Controller fileBytes, request.Type, request.MimeType, - request.EncryptedKey - ); + request.EncryptedKey, + request.OwnerType, + null) + { + OwnerType = request.OwnerType, + OwnerId = request.OwnerType == MediaOwnerType.Avatar + ? _currentUserService.GetCurrentUserId() + : null, + }; var result = await _mediaService.UploadMediaAsync(media); diff --git a/Govor.API/Controllers/ProfileController.cs b/Govor.API/Controllers/ProfileController.cs new file mode 100644 index 0000000..8e3eaee --- /dev/null +++ b/Govor.API/Controllers/ProfileController.cs @@ -0,0 +1,67 @@ +using AutoMapper; +using Govor.Application.Interfaces; +using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.Application.Interfaces.Medias; +using Govor.Contracts.DTOs; +using Govor.Data.Repositories.Exceptions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Govor.API.Controllers; + +[ApiController] +[Route("api/profile")] +[Authorize(Roles = "Admin,User")] +public class ProfileController : ControllerBase +{ + private readonly ILogger _logger; + private readonly IMediaService _mediaService; + private readonly IProfileService _profileService; + private readonly ICurrentUserService _currentUserService; + private readonly IMapper _mapper; + + public ProfileController( + ILogger logger, + IMediaService mediaService, + IProfileService profileService, + ICurrentUserService currentUserService, + IMapper mapper) + { + _logger = logger; + _mediaService = mediaService; + _profileService = profileService; + _currentUserService = currentUserService; + _mapper = mapper; + } + + [HttpGet("dowload")] + public async Task DowloadProfile() + { + try + { + var userId = _currentUserService.GetCurrentUserId(); + var user = await _profileService.GetUserProfileAsync(userId); + + if (user is null) + return NotFound("Profile not found"); + + var dto = _mapper.Map(user); + return Ok(dto); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex.Message); + return Forbid(ex.Message); + } + catch (NotFoundException ex) + { + _logger.LogWarning(ex, ex.Message); + return BadRequest(ex.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + return StatusCode(500, "Unexpected Error! Please try again later."); + } + } +} diff --git a/Govor.API/Controllers/SessionController.cs b/Govor.API/Controllers/SessionController.cs index ae600ee..489579f 100644 --- a/Govor.API/Controllers/SessionController.cs +++ b/Govor.API/Controllers/SessionController.cs @@ -15,6 +15,7 @@ public class SessionController : Controller { private readonly ILogger _logger; private readonly ICurrentUserService _currentUserService; + private readonly ICurrentUserSessionService _currentUserSessionService; private readonly IUserSessionReader _userSessionReader; private readonly IUserSessionRevoker _userSessionRevoker; private readonly IMapper _mapper; @@ -22,12 +23,14 @@ public class SessionController : Controller public SessionController( ILogger logger, ICurrentUserService currentUserService, + ICurrentUserSessionService currentUserSessionService, IUserSessionReader userSessionReader, IUserSessionRevoker userSessionRevoker, IMapper mapper) { _logger = logger; _currentUserService = currentUserService; + _currentUserSessionService = currentUserSessionService; _userSessionReader = userSessionReader; _userSessionRevoker = userSessionRevoker; _mapper = mapper; @@ -86,7 +89,40 @@ public class SessionController : Controller return StatusCode(500, "Unexpected Error! Please try again later."); } } - + + [HttpDelete("close")] + public async Task CloseCurrent() + { + try + { + await _userSessionRevoker.CloseSessionByIdAsync( + _currentUserSessionService.GetUserSessionId(), + _currentUserService.GetCurrentUserId()); + + return Ok(); + } + catch (InvalidOperationException ex) + { + _logger.LogError(ex, ex.Message); + return BadRequest(ex.Message); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, ex.Message); + return Forbid(ex.Message); + } + catch (NotFoundException ex) + { + _logger.LogWarning(ex, ex.Message); + return NotFound(ex.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + return StatusCode(500, "Unexpected Error! Please try again later."); + } + } + [HttpDelete("close/all")] public async Task CloseAllSessions() { diff --git a/Govor.API/Hubs/ChatsHub.cs b/Govor.API/Hubs/ChatsHub.cs index 127460f..07f64be 100644 --- a/Govor.API/Hubs/ChatsHub.cs +++ b/Govor.API/Hubs/ChatsHub.cs @@ -51,7 +51,7 @@ public class ChatsHub : Hub await base.OnConnectedAsync(); } - public override async Task OnDisconnectedAsync(Exception? exception) + public override async Task OnDisconnectedAsync(Exception exception) { var userId = _userAccessor.GetUserId(Context, true); if (userId != Guid.Empty) @@ -87,7 +87,7 @@ public class ChatsHub : Hub public async Task> Send(MessageRequest request) { - var senderId= _userAccessor.GetUserId(Context); + var senderId = _userAccessor.GetUserId(Context); if (string.IsNullOrWhiteSpace(request.EncryptedContent) && (request.MediaAttachments == null || !request.MediaAttachments.Any())) @@ -96,6 +96,12 @@ public class ChatsHub : Hub return HubResult.BadRequest("Message must contain content or media."); } + if (request.EncryptedContent.Length > 50_000) + { + _logger.LogWarning("User {SenderId} tried to send a too long message (length: {Length})", senderId, request.EncryptedContent.Length); + return HubResult.BadRequest("Message cannot exceed 50,000 characters."); + } + _logger.LogInformation("Sending message from {SenderId} to {RecipientId} ({RecipientType})", senderId, request.RecipientId, request.RecipientType); @@ -143,7 +149,6 @@ public class ChatsHub : Hub } } - public async Task> Remove(RemoveMessageRequest request) { var removerId = _userAccessor.GetUserId(Context); @@ -183,7 +188,6 @@ public class ChatsHub : Hub } } - public async Task> Edit(EditMessageRequest request) { var editor = _userAccessor.GetUserId(Context); @@ -232,7 +236,7 @@ public class ChatsHub : Hub } } - + #region common private UserMessageResponse BuildUserMessageResponse(Message message, Guid? replyToId) { return new UserMessageResponse @@ -305,4 +309,5 @@ public class ChatsHub : Hub _logger.LogWarning(ex, "{Msg}: {UserId} -> {TargetId}", msg, userId, targetId); return HubResult.NotFound("Message not found."); } + #endregion } \ No newline at end of file diff --git a/Govor.API/Hubs/ProfileHub.cs b/Govor.API/Hubs/ProfileHub.cs new file mode 100644 index 0000000..6728879 --- /dev/null +++ b/Govor.API/Hubs/ProfileHub.cs @@ -0,0 +1,140 @@ +using Govor.API.Common.SignalR.Helpers; +using Govor.Application.Interfaces; +using Govor.Contracts.Responses.SignalR; +using Govor.Core.Repositories.Groups; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; + +namespace Govor.API.Hubs; + +[Authorize] +public class ProfileHub : Hub +{ + private readonly IGroupsRepository _groupsRepository; + private readonly IFriendsService _friendsService; + private readonly IProfileService _profileService; + private readonly IHubUserAccessor _userAccessor; + private readonly ILogger _logger; + + public ProfileHub( + IGroupsRepository groupsRepository, + IFriendsService friendsService, + IProfileService profileService, + IHubUserAccessor userAccessor, + ILogger logger) + { + _groupsRepository = groupsRepository; + _friendsService = friendsService; + _profileService = profileService; + _userAccessor = userAccessor; + _logger = logger; + } + + public override async Task OnConnectedAsync() + { + var userId = _userAccessor.GetUserId(Context); + + // Добавляем в персональную группу + await Groups.AddToGroupAsync(Context.ConnectionId, $"user-{userId}"); + + // Friends + var friendships = await _friendsService.GetFriendsAsync(userId); + foreach (var friends in friendships) + await Groups.AddToGroupAsync(Context.ConnectionId, $"friends-{friends.Id}"); + + // Groups + var groups = await _groupsRepository.GetByUserIdAsync(userId); + foreach (var group in groups) + await Groups.AddToGroupAsync(Context.ConnectionId, $"group-{group.Id}"); + + _logger.LogInformation("User {UserId} connected with ConnectionId {ConnectionId}", userId, Context.ConnectionId); + + await base.OnConnectedAsync(); + } + + public override async Task OnDisconnectedAsync(Exception exception) + { + var userId = _userAccessor.GetUserId(Context); + + await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"user-{userId}"); + // Friends + var friendships = await _friendsService.GetFriendsAsync(userId); + foreach (var friendship in friendships) + await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"friends-{friendship.Id}"); + + // Groups + var groups = await _groupsRepository.GetByUserIdAsync(userId); + foreach (var group in groups) + await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"group-{group.Id}"); + + await base.OnDisconnectedAsync(exception); + } + + + public async Task> SetDescription(string description) + { + var userId = _userAccessor.GetUserId(Context); + + try + { + await _profileService.SetDescription(description, userId); + + var payload = new { userId, description }; + + await NotifyFriendsAndGroupsAsync(userId, "DescriptionUpdated", payload); + + return HubResult.Ok(true); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while updating the user's {userId} descripton!", userId); + return HubResult.Error("An unaccounted error on the server!"); + } + } + + public async Task> SetAvatar(Guid iconId) + { + var userId = _userAccessor.GetUserId(Context); + + try + { + if (iconId == Guid.Empty) + return HubResult.BadRequest("IconId can't be empty!"); + + await _profileService.SetNewIcon(userId, iconId); + + var payload = new { userId, iconId }; + + await NotifyFriendsAndGroupsAsync(userId, "AvatarUpdated", payload); + + return HubResult.Ok(true); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while updating the user's {userId} avatar {iconId}", userId, iconId); + return HubResult.Error("An unaccounted error on the server!"); + } + } + + private async Task NotifyFriendsAndGroupsAsync(Guid userId, string eventName, object payload) + { + var friendIds = await _friendsService.GetFriendsAsync(userId); + var groups = await _groupsRepository.GetByUserIdAsync(userId); + + var groupIds = groups.Select(g => g.Id).ToList(); + + await Clients.Group($"user-{userId}") // current user + .SendAsync(eventName, payload); + + foreach (var friendId in friendIds) + await Clients.Group($"friends-{friendId}") + .SendAsync(eventName, payload); + + foreach (var groupId in groupIds) + await Clients.Group($"group-{groupId}") + .SendAsync(eventName, payload); + + _logger.LogInformation("Sent {EventName} for {UserId} to {Friends} friends and {Groups} groups", + eventName, userId, friendIds.Count, groupIds.Count); + } +} diff --git a/Govor.API/Program.cs b/Govor.API/Program.cs index 935a705..65d1f28 100644 --- a/Govor.API/Program.cs +++ b/Govor.API/Program.cs @@ -19,7 +19,7 @@ builder.Services.AddCors(options => { options.AddPolicy("AllowFrontend", policy => { - policy.WithOrigins("http://localhost:5000", "https://5.129.212.144:5000") + policy.WithOrigins("https://localhost:7155", "http://localhost:7155") .AllowAnyHeader() .AllowAnyMethod() .AllowCredentials(); @@ -126,6 +126,7 @@ app.MapControllers(); app.MapHub("/hubs/chats"); app.MapHub("/hubs/friends"); +app.MapHub("/hubs/profiles"); app.MapSwagger().RequireAuthorization(); diff --git a/Govor.API/appsettings.json b/Govor.API/appsettings.json index ce20070..8d4a423 100644 --- a/Govor.API/appsettings.json +++ b/Govor.API/appsettings.json @@ -6,13 +6,13 @@ } }, "ConnectionStrings": { - "GovorDbContext": "Server=147.45.255.215;Port=3306;Database=artemy_DB;User=artemy;Password=LoxHuy))228Goy;" + "GovorDbContext": "Host=77.233.213.226;Port=5432;Database=GovorDb;Username=gen_user;Password=co|HPq}JI6D@,t;" }, - "UseMySql": true, - "AllowedHosts": "govor-team-govor-88b3.twc1.net;localhost;localhost:7155", + "UseMySql": false, + "AllowedHosts": "govor-team-govor-8ce1.twc1.net;localhost;localhost:7155", "JwtAccessOption": { "SecretKey": "Q89eY7zP7C4+TqLmHF4kw9xkF1E8Ru4Zpg+up9wFt9g=", - "Minutes": 5 + "Minutes": 10 }, "JwtRefreshOption": { "RefreshTokenLifetimeDays": 30 diff --git a/Govor.API/uploads/2025.11/1076a3b6-8b2c-435b-b253-5e6af7867b8e_Гол.png b/Govor.API/uploads/2025.11/1076a3b6-8b2c-435b-b253-5e6af7867b8e_Гол.png new file mode 100644 index 0000000..e69de29 diff --git a/Govor.API/uploads/2025.11/13352c29-be8e-406d-a75c-321988cb8097_Гол.png b/Govor.API/uploads/2025.11/13352c29-be8e-406d-a75c-321988cb8097_Гол.png new file mode 100644 index 0000000..e69de29 diff --git a/Govor.API/uploads/2025.11/33567cf9-66b0-48b2-b681-6f0d1ecc3559_Имба.png b/Govor.API/uploads/2025.11/33567cf9-66b0-48b2-b681-6f0d1ecc3559_Имба.png new file mode 100644 index 0000000000000000000000000000000000000000..2aac3463bb9a47acee3a0383b9c3a9faf508d2db GIT binary patch literal 707658 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%0A`INHXTU`2u$9w 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

_Jv)J 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 u*1?hgT*sA$iDt!2h$s|ewhgn2;C}!Q1Ofrn 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�RHuD9zZk3LoKJ6WVf< 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