diff --git a/backend/Tests/Admin/IntegrationTests/Knot.Modules.Admin.IntegrationTests.csproj b/backend/Tests/Admin/IntegrationTests/Knot.Modules.Admin.IntegrationTests.csproj
new file mode 100644
index 0000000..b28cb9d
--- /dev/null
+++ b/backend/Tests/Admin/IntegrationTests/Knot.Modules.Admin.IntegrationTests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/Tests/Admin/UnitTests/Knot.Modules.Admin.UnitTests.csproj b/backend/Tests/Admin/UnitTests/Knot.Modules.Admin.UnitTests.csproj
new file mode 100644
index 0000000..3d58229
--- /dev/null
+++ b/backend/Tests/Admin/UnitTests/Knot.Modules.Admin.UnitTests.csproj
@@ -0,0 +1,30 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/Tests/Admin/UnitTests/Knot.Modules.Settings.UnitTests.csproj b/backend/Tests/Admin/UnitTests/Knot.Modules.Settings.UnitTests.csproj
new file mode 100644
index 0000000..9b38437
--- /dev/null
+++ b/backend/Tests/Admin/UnitTests/Knot.Modules.Settings.UnitTests.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/Tests/Admin/UnitTests/ResetUserPasswordCommandHandlerTests.cs b/backend/Tests/Admin/UnitTests/ResetUserPasswordCommandHandlerTests.cs
new file mode 100644
index 0000000..3c27630
--- /dev/null
+++ b/backend/Tests/Admin/UnitTests/ResetUserPasswordCommandHandlerTests.cs
@@ -0,0 +1,58 @@
+using FluentAssertions;
+using Knot.Modules.Admin.Application.Admin.Commands;
+using Knot.Modules.Auth.Application.Abstractions;
+using Knot.Modules.Auth.Domain;
+using Knot.Shared.Kernel;
+using NSubstitute;
+using Xunit;
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Knot.Modules.Admin.UnitTests;
+
+public class ResetUserPasswordCommandHandlerTests
+{
+ private readonly IAuthUnitOfWork _authUnitOfWork;
+ private readonly IUserRepository _userRepository;
+ private readonly ResetUserPasswordCommandHandler _handler;
+
+ public ResetUserPasswordCommandHandlerTests()
+ {
+ _authUnitOfWork = Substitute.For();
+ _userRepository = Substitute.For();
+ _handler = new ResetUserPasswordCommandHandler(_userRepository, _authUnitOfWork);
+ }
+
+ [Fact]
+ public async Task Handle_ShouldReturnError_WhenUserNotFound()
+ {
+ // Arrange
+ var command = new ResetUserPasswordCommand(Guid.NewGuid(), "NewP@ssw0rd");
+ _userRepository.GetByIdAsync(command.UserId, Arg.Any()).Returns((User?)null);
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ result.IsFailure.Should().BeTrue();
+ result.Error.Should().Be(AuthErrors.UserNotFound);
+ }
+
+ [Fact]
+ public async Task Handle_ShouldSucceed_WhenUserFound()
+ {
+ // Arrange
+ var user = User.Create("User", "pass", "salt", "admin");
+ var command = new ResetUserPasswordCommand(user.Id, "NewP@ssw0rd");
+
+ _userRepository.GetByIdAsync(command.UserId, Arg.Any()).Returns(user);
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ result.IsSuccess.Should().BeTrue();
+ await _authUnitOfWork.Received(1).SaveChangesAsync(Arg.Any());
+ }
+}
diff --git a/backend/Tests/Auth/IntegrationTests/Knot.Modules.Auth.IntegrationTests.csproj b/backend/Tests/Auth/IntegrationTests/Knot.Modules.Auth.IntegrationTests.csproj
new file mode 100644
index 0000000..1607c89
--- /dev/null
+++ b/backend/Tests/Auth/IntegrationTests/Knot.Modules.Auth.IntegrationTests.csproj
@@ -0,0 +1,23 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/Tests/Auth/UnitTests/Knot.Modules.Auth.UnitTests.csproj b/backend/Tests/Auth/UnitTests/Knot.Modules.Auth.UnitTests.csproj
new file mode 100644
index 0000000..ab17424
--- /dev/null
+++ b/backend/Tests/Auth/UnitTests/Knot.Modules.Auth.UnitTests.csproj
@@ -0,0 +1,32 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/Tests/Auth/UnitTests/LoginUserCommandHandlerTests.cs b/backend/Tests/Auth/UnitTests/LoginUserCommandHandlerTests.cs
new file mode 100644
index 0000000..518e53d
--- /dev/null
+++ b/backend/Tests/Auth/UnitTests/LoginUserCommandHandlerTests.cs
@@ -0,0 +1,88 @@
+using FluentAssertions;
+using NSubstitute;
+using Knot.Modules.Auth.Application.Abstractions;
+using Knot.Modules.Auth.Application.Users.Login;
+using Knot.Modules.Auth.Domain;
+using Knot.Shared.Kernel;
+using Knot.Modules.Auth.Application.Users.Auth;
+using Xunit;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Knot.Modules.Auth.UnitTests;
+
+public class LoginUserCommandHandlerTests
+{
+ private readonly IUserRepository _userRepository;
+ private readonly IJwtTokenProvider _tokenProvider;
+ private readonly LoginUserCommandHandler _handler;
+
+ public LoginUserCommandHandlerTests()
+ {
+ _userRepository = Substitute.For();
+ _tokenProvider = Substitute.For();
+ _handler = new LoginUserCommandHandler(_userRepository, _tokenProvider);
+ }
+
+ [Fact]
+ public async Task Handle_ShouldReturnToken_WhenCredentialsAreValid()
+ {
+ // Arrange
+ var password = "password123";
+ var passwordHash = BCrypt.Net.BCrypt.HashPassword(password);
+ var user = User.Create("testuser", passwordHash, "Test User", null, null);
+
+ var command = new LoginUserCommand("testuser", password);
+
+ _userRepository.GetByUsernameAsync(command.Username, Arg.Any())
+ .Returns(user);
+
+ _tokenProvider.Generate(user).Returns("valid-jwt-token");
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ result.IsSuccess.Should().BeTrue();
+ result.Value.Token.Should().Be("valid-jwt-token");
+ result.Value.User.Username.Should().Be("testuser");
+ }
+
+ [Fact]
+ public async Task Handle_ShouldReturnFailure_WhenUserDoesNotExist()
+ {
+ // Arrange
+ var command = new LoginUserCommand("nonexistent", "password123");
+ _userRepository.GetByUsernameAsync(command.Username, Arg.Any())
+ .Returns((User)null!);
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ result.IsFailure.Should().BeTrue();
+ result.Error.Code.Should().Be(AuthErrors.IdentityInvalidCredentials.Code);
+ }
+
+ [Fact]
+ public async Task Handle_ShouldReturnFailure_WhenPasswordIsInvalid()
+ {
+ // Arrange
+ var correctPassword = "correctPassword";
+ var passwordHash = BCrypt.Net.BCrypt.HashPassword(correctPassword);
+ var user = User.Create("testuser", passwordHash, "Test User", null, null);
+
+ var command = new LoginUserCommand("testuser", "wrongPassword");
+
+ _userRepository.GetByUsernameAsync(command.Username, Arg.Any())
+ .Returns(user);
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ result.IsFailure.Should().BeTrue();
+ result.Error.Code.Should().Be(AuthErrors.IdentityInvalidCredentials.Code);
+ }
+}
+
diff --git a/backend/Tests/Conversations/UnitTests/Chats/CreateChatCommandHandlerTests.cs b/backend/Tests/Conversations/UnitTests/Chats/CreateChatCommandHandlerTests.cs
new file mode 100644
index 0000000..4de4ea6
--- /dev/null
+++ b/backend/Tests/Conversations/UnitTests/Chats/CreateChatCommandHandlerTests.cs
@@ -0,0 +1,54 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using NSubstitute;
+using Xunit;
+using Knot.Shared.Kernel;
+using Knot.Modules.Conversations.Domain;
+using Knot.Modules.Messaging.Domain;
+using Knot.Modules.Conversations.Application.Abstractions;
+using Knot.Modules.Conversations.Application.Chats.Create;
+
+namespace Knot.Modules.Conversations.UnitTests.Chats;
+
+public class CreateChatCommandHandlerTests
+{
+ private readonly IChatRepository _chatRepository;
+ private readonly IChatsUnitOfWork _unitOfWork;
+ private readonly CreateChatCommandHandler _handler;
+
+ public CreateChatCommandHandlerTests()
+ {
+ _chatRepository = Substitute.For();
+ _unitOfWork = Substitute.For();
+ _handler = new CreateChatCommandHandler(_chatRepository, _unitOfWork);
+ }
+
+ [Fact]
+ public async Task Handle_ShouldCreateChatAndAddMembers()
+ {
+ // Arrange
+ var request = new CreateChatCommand("Test Group", ChatType.Group, new List { Guid.NewGuid(), Guid.NewGuid() });
+
+ // Act
+ var result = await _handler.Handle(request, CancellationToken.None);
+
+ // Assert
+ result.IsSuccess.Should().BeTrue();
+ result.Value.Should().NotBeEmpty();
+
+ _chatRepository.Received(1).Add(Arg.Is(c =>
+ c.Name == "Test Group" &&
+ c.Type == ChatType.Group &&
+ c.Members.Count == 2 &&
+ c.Members.First().Role == ChatRole.Owner &&
+ c.Members.Last().Role == ChatRole.Member));
+
+ await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any());
+ }
+}
+
+
diff --git a/backend/Tests/Conversations/UnitTests/Chats/GetChatsQueryHandlerTests.cs b/backend/Tests/Conversations/UnitTests/Chats/GetChatsQueryHandlerTests.cs
new file mode 100644
index 0000000..a009178
--- /dev/null
+++ b/backend/Tests/Conversations/UnitTests/Chats/GetChatsQueryHandlerTests.cs
@@ -0,0 +1,74 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using NSubstitute;
+using Xunit;
+using Knot.Shared.Kernel;
+using Knot.Modules.Conversations.Domain;
+using Knot.Modules.Messaging.Domain;
+using Knot.Modules.Conversations.Application.Abstractions;
+using Knot.Modules.Conversations.Application.Chats.GetChats;
+using Knot.Modules.Conversations.Application.DTOs;
+
+namespace Knot.Modules.Conversations.UnitTests.Chats;
+
+public class GetChatsQueryHandlerTests
+{
+ private readonly IChatRepository _chatRepository;
+ private readonly IUserDisplayNameProvider _userProvider;
+ private readonly IMessageRepository _messageRepository;
+ private readonly IMessageReactionRepository _reactionRepository;
+ private readonly GetChatsQueryHandler _handler;
+
+ public GetChatsQueryHandlerTests()
+ {
+ _chatRepository = Substitute.For();
+ _userProvider = Substitute.For();
+ _messageRepository = Substitute.For();
+ _reactionRepository = Substitute.For();
+
+ _handler = new GetChatsQueryHandler(_chatRepository, _userProvider, _messageRepository, _reactionRepository);
+ }
+
+ [Fact]
+ public async Task Handle_ShouldReturnUserChats_WhenTheyExist()
+ {
+ // Arrange
+ var userId = Guid.NewGuid();
+ var request = new GetChatsQuery(userId);
+
+ var chat1 = Chat.Create("Test Chat", ChatType.Group);
+ chat1.GetType().GetProperty("Id")?.SetValue(chat1, Guid.NewGuid());
+ chat1.AddMember(userId, ChatRole.Owner);
+
+ var chat2 = Chat.Create("Personal", ChatType.Personal);
+ chat2.GetType().GetProperty("Id")?.SetValue(chat2, Guid.NewGuid());
+ chat2.AddMember(userId, ChatRole.Member);
+
+ _chatRepository.GetUserChatsAsync(userId, Arg.Any())
+ .Returns(new List { chat1, chat2 });
+
+ _messageRepository.GetChatMessagesAsync(Arg.Any(), 1, 0, Arg.Any())
+ .Returns(new List());
+
+ _userProvider.GetUsersInfoAsync(Arg.Any>(), Arg.Any())
+ .Returns(new Dictionary());
+
+ // Act
+ var result = await _handler.Handle(request, CancellationToken.None);
+
+ // Assert
+ result.IsSuccess.Should().BeTrue();
+ result.Value.Should().NotBeNull();
+
+ // It always appends synthetic "favorites" chat at the end if not found
+ result.Value.Count.Should().Be(2);
+ result.Value.Any(c => c.Name == "Test Chat").Should().BeTrue();
+ result.Value.Any(c => c.Type == "favorites").Should().BeTrue();
+ }
+}
+
+
diff --git a/backend/Tests/Conversations/UnitTests/GetOrCreateFavoritesCommandHandlerTests.cs b/backend/Tests/Conversations/UnitTests/GetOrCreateFavoritesCommandHandlerTests.cs
new file mode 100644
index 0000000..0368f51
--- /dev/null
+++ b/backend/Tests/Conversations/UnitTests/GetOrCreateFavoritesCommandHandlerTests.cs
@@ -0,0 +1,74 @@
+using FluentAssertions;
+using NSubstitute;
+using Knot.Modules.Conversations.Application.Abstractions;
+using Knot.Modules.Conversations.Application.Chats.GetOrCreateFavorites;
+using Knot.Modules.Conversations.Domain;
+using Knot.Modules.Messaging.Domain;
+using Knot.Shared.Kernel;
+using Xunit;
+
+namespace Knot.Modules.Conversations.UnitTests;
+
+public class GetOrCreateFavoritesCommandHandlerTests
+{
+ private readonly IChatRepository _chatRepository;
+ private readonly IChatsUnitOfWork _unitOfWork;
+ private readonly GetOrCreateFavoritesCommandHandler _handler;
+
+ public GetOrCreateFavoritesCommandHandlerTests()
+ {
+ _chatRepository = Substitute.For();
+ _unitOfWork = Substitute.For();
+ _handler = new GetOrCreateFavoritesCommandHandler(_chatRepository, _unitOfWork);
+ }
+
+ [Fact]
+ public async Task Handle_ShouldReturnExistingChat_WhenFavoritesAlreadyExists()
+ {
+ // Arrange
+ var userId = Guid.NewGuid();
+ var existingChat = Chat.Create("Избранное", ChatType.Favorites);
+
+ _chatRepository.GetFavoritesAsync(userId, Arg.Any())
+ .Returns(existingChat);
+
+ var command = new GetOrCreateFavoritesCommand(userId);
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ result.IsSuccess.Should().BeTrue();
+ result.Value.Should().Be(existingChat.Id);
+
+ _chatRepository.DidNotReceive().Add(Arg.Any());
+ await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any());
+ }
+
+ [Fact]
+ public async Task Handle_ShouldCreateNewChat_WhenFavoritesDoesNotExist()
+ {
+ // Arrange
+ var userId = Guid.NewGuid();
+ _chatRepository.GetFavoritesAsync(userId, Arg.Any())
+ .Returns((Chat)null!);
+
+ var command = new GetOrCreateFavoritesCommand(userId);
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ result.IsSuccess.Should().BeTrue();
+ result.Value.Should().NotBeEmpty();
+
+ _chatRepository.Received(1).Add(Arg.Is(c =>
+ c.Type == ChatType.Favorites &&
+ c.Name == "Избранное" &&
+ c.Members.Any(m => m.UserId == userId)));
+
+ await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any());
+ }
+}
+
+
diff --git a/backend/Tests/Conversations/UnitTests/Knot.Modules.Conversations.UnitTests.csproj b/backend/Tests/Conversations/UnitTests/Knot.Modules.Conversations.UnitTests.csproj
new file mode 100644
index 0000000..11589b9
--- /dev/null
+++ b/backend/Tests/Conversations/UnitTests/Knot.Modules.Conversations.UnitTests.csproj
@@ -0,0 +1,33 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/Tests/Conversations/UnitTests/Messages/SendMessageCommandHandlerTests.cs b/backend/Tests/Conversations/UnitTests/Messages/SendMessageCommandHandlerTests.cs
new file mode 100644
index 0000000..8e5192a
--- /dev/null
+++ b/backend/Tests/Conversations/UnitTests/Messages/SendMessageCommandHandlerTests.cs
@@ -0,0 +1,107 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using MediatR;
+using NSubstitute;
+using Xunit;
+using Knot.Shared.Kernel;
+using Knot.Modules.Conversations.Domain;
+using Knot.Modules.Messaging.Domain;
+using Knot.Modules.Conversations.Application.Abstractions;
+using Knot.Modules.Conversations.Application.Messages.Send;
+using Knot.Modules.Settings.Application.Settings.Abstractions;
+using Knot.Modules.Settings.Application.Settings.DTOs;
+
+namespace Knot.Modules.Conversations.UnitTests.Messages;
+
+public class SendMessageCommandHandlerTests
+{
+ private readonly IChatRepository _chatRepository;
+ private readonly IMessageRepository _messageRepository;
+ private readonly IChatsUnitOfWork _unitOfWork;
+ private readonly IMediator _mediator;
+ private readonly IMessagesSettings _messagesSettings;
+ private readonly SendMessageCommandHandler _handler;
+
+ public SendMessageCommandHandlerTests()
+ {
+ _chatRepository = Substitute.For();
+ _messageRepository = Substitute.For();
+ _unitOfWork = Substitute.For();
+ _mediator = Substitute.For();
+ _messagesSettings = Substitute.For();
+
+ var config = new Knot.Modules.Settings.Application.Settings.DTOs.MessagesConfig();
+ _messagesSettings.Current.Returns(config);
+
+ _handler = new SendMessageCommandHandler(_chatRepository, _messageRepository, _unitOfWork, _mediator, _messagesSettings);
+ }
+
+ [Fact]
+ public async Task Handle_ShouldReturnFailure_WhenChatNotFound()
+ {
+ // Arrange
+ var command = new SendMessageCommand(Guid.NewGuid(), Guid.NewGuid(), "Hello", "text");
+ _chatRepository.GetByIdAsync(command.ChatId, Arg.Any())
+ .Returns((Chat)null!);
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ result.IsFailure.Should().BeTrue();
+ result.Error.Code.Should().Be(ChatErrors.ChatsNotFound.Code);
+ }
+
+ [Fact]
+ public async Task Handle_ShouldReturnFailure_WhenSenderIsNotMember()
+ {
+ // Arrange
+ var chat = Chat.Create("Test", ChatType.Group);
+ var command = new SendMessageCommand(chat.Id, Guid.NewGuid(), "Hello", "text");
+
+ _chatRepository.GetByIdAsync(command.ChatId, Arg.Any())
+ .Returns(chat);
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ result.IsFailure.Should().BeTrue();
+ result.Error.Code.Should().Be(ChatErrors.ChatsForbidden.Code);
+ }
+
+ [Fact]
+ public async Task Handle_ShouldCreateMessage_WhenAuthorized()
+ {
+ // Arrange
+ var senderId = Guid.NewGuid();
+ var chat = Chat.Create("Test", ChatType.Group);
+ chat.AddMember(senderId, ChatRole.Member);
+
+ var command = new SendMessageCommand(chat.Id, senderId, "Hello", "text");
+
+ _chatRepository.GetByIdAsync(command.ChatId, Arg.Any())
+ .Returns(chat);
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ result.IsSuccess.Should().BeTrue();
+ result.Value.Should().NotBeEmpty();
+
+ _messageRepository.Received(1).Add(Arg.Is(m =>
+ m.ChatId == chat.Id &&
+ m.SenderId == senderId &&
+ m.Content == "Hello" &&
+ m.Type == "text"));
+
+ await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any());
+ }
+}
+
+
diff --git a/backend/Tests/Federation/IntegrationTests/Knot.Modules.Federation.IntegrationTests.csproj b/backend/Tests/Federation/IntegrationTests/Knot.Modules.Federation.IntegrationTests.csproj
new file mode 100644
index 0000000..1607c89
--- /dev/null
+++ b/backend/Tests/Federation/IntegrationTests/Knot.Modules.Federation.IntegrationTests.csproj
@@ -0,0 +1,23 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/Tests/Federation/UnitTests/Knot.Modules.Federation.UnitTests.csproj b/backend/Tests/Federation/UnitTests/Knot.Modules.Federation.UnitTests.csproj
new file mode 100644
index 0000000..c5416d2
--- /dev/null
+++ b/backend/Tests/Federation/UnitTests/Knot.Modules.Federation.UnitTests.csproj
@@ -0,0 +1,30 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/Tests/Klipy/UnitTests/Knot.Modules.Klipy.UnitTests.csproj b/backend/Tests/Klipy/UnitTests/Knot.Modules.Klipy.UnitTests.csproj
new file mode 100644
index 0000000..21ebf72
--- /dev/null
+++ b/backend/Tests/Klipy/UnitTests/Knot.Modules.Klipy.UnitTests.csproj
@@ -0,0 +1,30 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/Tests/Messaging/IntegrationTests/Knot.Modules.Messaging.IntegrationTests.csproj b/backend/Tests/Messaging/IntegrationTests/Knot.Modules.Messaging.IntegrationTests.csproj
new file mode 100644
index 0000000..1607c89
--- /dev/null
+++ b/backend/Tests/Messaging/IntegrationTests/Knot.Modules.Messaging.IntegrationTests.csproj
@@ -0,0 +1,23 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/Tests/Messaging/UnitTests/Knot.Modules.Messaging.UnitTests.csproj b/backend/Tests/Messaging/UnitTests/Knot.Modules.Messaging.UnitTests.csproj
new file mode 100644
index 0000000..89029aa
--- /dev/null
+++ b/backend/Tests/Messaging/UnitTests/Knot.Modules.Messaging.UnitTests.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/Tests/Relations/IntegrationTests/Knot.Modules.Relations.IntegrationTests.csproj b/backend/Tests/Relations/IntegrationTests/Knot.Modules.Relations.IntegrationTests.csproj
new file mode 100644
index 0000000..1607c89
--- /dev/null
+++ b/backend/Tests/Relations/IntegrationTests/Knot.Modules.Relations.IntegrationTests.csproj
@@ -0,0 +1,23 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/Tests/Relations/UnitTests/Knot.Modules.Relations.UnitTests.csproj b/backend/Tests/Relations/UnitTests/Knot.Modules.Relations.UnitTests.csproj
new file mode 100644
index 0000000..33cc70b
--- /dev/null
+++ b/backend/Tests/Relations/UnitTests/Knot.Modules.Relations.UnitTests.csproj
@@ -0,0 +1,30 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/Tests/Shared/IntegrationTests.Shared/BaseIntegrationTest.cs b/backend/Tests/Shared/IntegrationTests.Shared/BaseIntegrationTest.cs
new file mode 100644
index 0000000..8645865
--- /dev/null
+++ b/backend/Tests/Shared/IntegrationTests.Shared/BaseIntegrationTest.cs
@@ -0,0 +1,88 @@
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using System.Net.Http;
+using Testcontainers.PostgreSql;
+using Testcontainers.MongoDb;
+using Xunit;
+using System.Threading.Tasks;
+using System.Collections.Generic;
+using Knot.Shared.Kernel;
+using NSubstitute;
+
+namespace Knot.IntegrationTests;
+
+public abstract class BaseIntegrationTest : IAsyncLifetime
+{
+ protected readonly PostgreSqlContainer _postgresContainer = new PostgreSqlBuilder()
+ .WithImage("postgres:15-alpine")
+ .Build();
+
+ protected readonly MongoDbContainer _mongoContainer = new MongoDbBuilder()
+ .WithImage("mongo:6.0")
+ .Build();
+
+ protected HttpClient _client;
+ protected WebApplicationFactory _factory;
+ protected readonly Guid _userId = Guid.NewGuid();
+ protected readonly IUserContext _userContextMock = Substitute.For();
+
+ public virtual async Task InitializeAsync()
+ {
+ await _postgresContainer.StartAsync();
+ await _mongoContainer.StartAsync();
+
+ _userContextMock.UserId.Returns(_userId);
+ _userContextMock.IsAuthenticated.Returns(true);
+
+ _factory = new IntegrationTestWebApplicationFactory(
+ _postgresContainer.GetConnectionString(),
+ _mongoContainer.GetConnectionString(),
+ _userContextMock);
+
+ _client = _factory.CreateClient();
+ }
+
+ public virtual async Task DisposeAsync()
+ {
+ await _postgresContainer.StopAsync();
+ await _mongoContainer.StopAsync();
+ _factory?.Dispose();
+ _client?.Dispose();
+ }
+
+ private class IntegrationTestWebApplicationFactory : WebApplicationFactory
+ {
+ private readonly string _pgConnectionString;
+ private readonly string _mongoConnectionString;
+ private readonly IUserContext _userContext;
+
+ public IntegrationTestWebApplicationFactory(string pg, string mongo, IUserContext userContext)
+ {
+ _pgConnectionString = pg;
+ _mongoConnectionString = mongo;
+ _userContext = userContext;
+ }
+
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ builder.ConfigureAppConfiguration((context, config) =>
+ {
+ config.AddInMemoryCollection(new Dictionary
+ {
+ ["ConnectionStrings:DefaultConnection"] = _pgConnectionString,
+ ["ConnectionStrings:MongoConnection"] = _mongoConnectionString,
+ ["DATABASE_URL"] = _pgConnectionString,
+ ["MONGO_CONNECTION"] = _mongoConnectionString,
+ ["KNOT_MASTER_ENCRYPTION_KEY"] = "TestEncryptionKey_32CharactersLong!"
+ });
+ });
+
+ builder.ConfigureTestServices(services => {
+ services.AddScoped(_ => _userContext);
+ });
+ }
+ }
+}
diff --git a/backend/Tests/Shared/IntegrationTests.Shared/Knot.IntegrationTests.Shared.csproj b/backend/Tests/Shared/IntegrationTests.Shared/Knot.IntegrationTests.Shared.csproj
new file mode 100644
index 0000000..b74a66f
--- /dev/null
+++ b/backend/Tests/Shared/IntegrationTests.Shared/Knot.IntegrationTests.Shared.csproj
@@ -0,0 +1,38 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/Tests/Storage/UnitTests/Knot.Modules.Storage.UnitTests.csproj b/backend/Tests/Storage/UnitTests/Knot.Modules.Storage.UnitTests.csproj
new file mode 100644
index 0000000..2c59df3
--- /dev/null
+++ b/backend/Tests/Storage/UnitTests/Knot.Modules.Storage.UnitTests.csproj
@@ -0,0 +1,30 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/Tests/Stories/IntegrationTests/Knot.Modules.Stories.IntegrationTests.csproj b/backend/Tests/Stories/IntegrationTests/Knot.Modules.Stories.IntegrationTests.csproj
new file mode 100644
index 0000000..df3476d
--- /dev/null
+++ b/backend/Tests/Stories/IntegrationTests/Knot.Modules.Stories.IntegrationTests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/Tests/Stories/IntegrationTests/StoriesTests.cs b/backend/Tests/Stories/IntegrationTests/StoriesTests.cs
new file mode 100644
index 0000000..3a9d464
--- /dev/null
+++ b/backend/Tests/Stories/IntegrationTests/StoriesTests.cs
@@ -0,0 +1,34 @@
+using Knot.IntegrationTests;
+using FluentAssertions;
+using Knot.Modules.Stories.Application.Stories.Commands.CreateStory;
+using Knot.Shared.Kernel;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+using NSubstitute;
+using System.Net;
+using System.Net.Http.Json;
+using Xunit;
+using System;
+using System.Threading.Tasks;
+
+namespace Knot.IntegrationTests.Stories;
+
+public class StoriesTests : BaseIntegrationTest
+{
+ [Fact]
+ public async Task CreateStory_ShouldReturnOk_WhenValidRequest()
+ {
+ // Arrange
+ var request = new CreateStoryRequest("Text", null, "Hello Integration Test", null);
+
+ // Act
+ var response = await _client.PostAsJsonAsync("api/stories", request);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var content = await response.Content.ReadFromJsonAsync();
+ }
+}
+
+public record CreateStoryRequest(string Type, string? MediaUrl, string? Content, string? BgColor);
diff --git a/backend/Tests/Stories/UnitTests/CreateStoryCommandHandlerTests.cs b/backend/Tests/Stories/UnitTests/CreateStoryCommandHandlerTests.cs
new file mode 100644
index 0000000..604e657
--- /dev/null
+++ b/backend/Tests/Stories/UnitTests/CreateStoryCommandHandlerTests.cs
@@ -0,0 +1,76 @@
+using FluentAssertions;
+using Knot.Modules.Stories.Application.Stories.Commands.CreateStory;
+using Knot.Modules.Stories.Domain;
+using Knot.Shared.Kernel;
+using Knot.Modules.Settings.Application.Settings.Abstractions;
+using Knot.Modules.Settings.Application.Settings.DTOs;
+using Knot.Modules.Stories.Application.Abstractions;
+using NSubstitute;
+using Xunit;
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Knot.Modules.Stories.UnitTests;
+
+public class CreateStoryCommandHandlerTests
+{
+ private readonly IStoryRepository _storyRepository;
+ private readonly ISettingsService _settingsService;
+ private readonly CreateStoryCommandHandler _handler;
+
+ public CreateStoryCommandHandlerTests()
+ {
+ _storyRepository = Substitute.For();
+ _settingsService = Substitute.For();
+ _handler = new CreateStoryCommandHandler(_storyRepository, _settingsService);
+ }
+
+ [Fact]
+ public async Task Handle_ShouldSucceed_WhenValidRequest()
+ {
+ // Arrange
+ var command = new CreateStoryCommand(Guid.NewGuid(), "Text", null, "Hello Story", null);
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ result.IsSuccess.Should().BeTrue();
+ await _storyRepository.Received(1).AddAsync(Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task Handle_ShouldFail_WhenKlipyDisabledAndKlipyType()
+ {
+ // Arrange
+ var command = new CreateStoryCommand(Guid.NewGuid(), "Klipy", "http://klipy.com/gif", null, null);
+ var settings = new SystemSettingsDto { Klipy = new KlipyConfig { Enabled = false } };
+ _settingsService.GetSettingsAsync(Arg.Any()).Returns(settings);
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ result.IsFailure.Should().BeTrue();
+ result.Error.Code.Should().Be("Stories.KlipyDisabled");
+ await _storyRepository.DidNotReceive().AddAsync(Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task Handle_ShouldSucceed_WhenKlipyEnabledAndKlipyType()
+ {
+ // Arrange
+ var command = new CreateStoryCommand(Guid.NewGuid(), "Klipy", "http://klipy.com/gif", null, null);
+ var settings = new SystemSettingsDto { Klipy = new KlipyConfig { Enabled = true } };
+ _settingsService.GetSettingsAsync(Arg.Any()).Returns(settings);
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ result.IsSuccess.Should().BeTrue();
+ await _storyRepository.Received(1).AddAsync(Arg.Any(), Arg.Any());
+ }
+}
+
diff --git a/backend/Tests/Stories/UnitTests/DeleteStoryCommandHandlerTests.cs b/backend/Tests/Stories/UnitTests/DeleteStoryCommandHandlerTests.cs
new file mode 100644
index 0000000..48795ad
--- /dev/null
+++ b/backend/Tests/Stories/UnitTests/DeleteStoryCommandHandlerTests.cs
@@ -0,0 +1,76 @@
+using FluentAssertions;
+using Knot.Modules.Stories.Application.Stories.Commands.DeleteStory;
+using Knot.Modules.Stories.Domain;
+using Knot.Shared.Kernel;
+using Knot.Modules.Stories.Application.Abstractions;
+using NSubstitute;
+using Xunit;
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Knot.Modules.Stories.UnitTests;
+
+public class DeleteStoryCommandHandlerTests
+{
+ private readonly IStoryRepository _storyRepository;
+ private readonly DeleteStoryCommandHandler _handler;
+
+ public DeleteStoryCommandHandlerTests()
+ {
+ _storyRepository = Substitute.For();
+ _handler = new DeleteStoryCommandHandler(_storyRepository);
+ }
+
+ [Fact]
+ public async Task Handle_ShouldReturnError_WhenStoryNotFound()
+ {
+ // Arrange
+ var command = new DeleteStoryCommand(Guid.NewGuid(), Guid.NewGuid());
+ _storyRepository.GetByIdAsync(command.StoryId, Arg.Any()).Returns((Story?)null);
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ result.IsFailure.Should().BeTrue();
+ result.Error.Should().Be(StoryErrors.StoryNotFound);
+ }
+
+ [Fact]
+ public async Task Handle_ShouldReturnError_WhenUserIsNotOwner()
+ {
+ // Arrange
+ var ownerId = Guid.NewGuid();
+ var viewerId = Guid.NewGuid();
+ var story = Story.Create(ownerId, StoryType.Text, null, "Content", null);
+ var command = new DeleteStoryCommand(viewerId, story.Id);
+
+ _storyRepository.GetByIdAsync(command.StoryId, Arg.Any()).Returns(story);
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ result.IsFailure.Should().BeTrue();
+ result.Error.Should().Be(StoryErrors.Unauthorized);
+ }
+
+ [Fact]
+ public async Task Handle_ShouldSucceed_WhenOwnerDeletes()
+ {
+ // Arrange
+ var ownerId = Guid.NewGuid();
+ var story = Story.Create(ownerId, StoryType.Text, null, "Content", null);
+ var command = new DeleteStoryCommand(ownerId, story.Id);
+
+ _storyRepository.GetByIdAsync(command.StoryId, Arg.Any()).Returns(story);
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ result.IsSuccess.Should().BeTrue();
+ await _storyRepository.Received(1).DeleteAsync(story, Arg.Any());
+ }
+}
diff --git a/backend/Tests/Stories/UnitTests/Knot.Modules.Stories.UnitTests.csproj b/backend/Tests/Stories/UnitTests/Knot.Modules.Stories.UnitTests.csproj
new file mode 100644
index 0000000..71d70a1
--- /dev/null
+++ b/backend/Tests/Stories/UnitTests/Knot.Modules.Stories.UnitTests.csproj
@@ -0,0 +1,30 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/Tests/TelegramImport/UnitTests/Knot.Modules.TelegramImport.UnitTests.csproj b/backend/Tests/TelegramImport/UnitTests/Knot.Modules.TelegramImport.UnitTests.csproj
new file mode 100644
index 0000000..bf92713
--- /dev/null
+++ b/backend/Tests/TelegramImport/UnitTests/Knot.Modules.TelegramImport.UnitTests.csproj
@@ -0,0 +1,30 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/Tests/Users/UnitTests/Knot.Modules.Profiles.UnitTests.csproj b/backend/Tests/Users/UnitTests/Knot.Modules.Profiles.UnitTests.csproj
new file mode 100644
index 0000000..3e3dac3
--- /dev/null
+++ b/backend/Tests/Users/UnitTests/Knot.Modules.Profiles.UnitTests.csproj
@@ -0,0 +1,30 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/Tests/Users/UnitTests/UpdateProfileCommandHandlerTests.cs b/backend/Tests/Users/UnitTests/UpdateProfileCommandHandlerTests.cs
new file mode 100644
index 0000000..3739a53
--- /dev/null
+++ b/backend/Tests/Users/UnitTests/UpdateProfileCommandHandlerTests.cs
@@ -0,0 +1,38 @@
+using FluentAssertions;
+using Knot.Modules.Profiles.Application.Profiles.UpdateProfile;
+using Knot.Modules.Profiles.Domain;
+using Knot.Shared.Kernel;
+using NSubstitute;
+using Xunit;
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Knot.Modules.Profiles.Application.Abstractions;
+
+namespace Knot.Modules.Profiles.UnitTests;
+
+public class UpdateProfileCommandHandlerTests
+{
+ private readonly IProfileRepository _profileRepository;
+ private readonly UpdateProfileCommandHandler _handler;
+
+ public UpdateProfileCommandHandlerTests()
+ {
+ _profileRepository = Substitute.For();
+ _handler = new UpdateProfileCommandHandler(_profileRepository);
+ }
+
+ [Fact]
+ public async Task Handle_ShouldReturnError_WhenProfileNotFound()
+ {
+ // Arrange
+ var command = new UpdateProfileCommand(Guid.NewGuid(), "FirstName", "Bio", null);
+ _profileRepository.GetByIdAsync(command.UserId, Arg.Any()).Returns((ProfileDocument?)null);
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ result.IsFailure.Should().BeTrue();
+ }
+}
diff --git a/backend/Tests/WebRtc/UnitTests/Knot.Modules.WebRtc.UnitTests.csproj b/backend/Tests/WebRtc/UnitTests/Knot.Modules.WebRtc.UnitTests.csproj
new file mode 100644
index 0000000..be78aa8
--- /dev/null
+++ b/backend/Tests/WebRtc/UnitTests/Knot.Modules.WebRtc.UnitTests.csproj
@@ -0,0 +1,30 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/src/Modules/Auth/Application/Abstractions/IIdentityDbContext.cs b/backend/src/Modules/Auth/Application/Abstractions/IIdentityDbContext.cs
new file mode 100644
index 0000000..a323aa8
--- /dev/null
+++ b/backend/src/Modules/Auth/Application/Abstractions/IIdentityDbContext.cs
@@ -0,0 +1,14 @@
+using System.Threading;
+using System.Threading.Tasks;
+using Knot.Modules.Auth.Domain;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+
+namespace Knot.Modules.Auth.Application.Abstractions;
+
+public interface IAuthDbContext
+{
+ DbSet Users { get; }
+ Task SaveChangesAsync(CancellationToken cancellationToken);
+}
+
diff --git a/backend/src/Modules/Auth/Application/Abstractions/IIdentityUnitOfWork.cs b/backend/src/Modules/Auth/Application/Abstractions/IIdentityUnitOfWork.cs
new file mode 100644
index 0000000..11f260e
--- /dev/null
+++ b/backend/src/Modules/Auth/Application/Abstractions/IIdentityUnitOfWork.cs
@@ -0,0 +1,11 @@
+using Knot.Shared.Kernel;
+
+namespace Knot.Modules.Auth.Application.Abstractions;
+
+///
+/// Unit of Work специфичный для модуля Identity.
+///
+public interface IAuthUnitOfWork : IUnitOfWork
+{
+}
+
diff --git a/backend/src/Modules/Auth/Application/Abstractions/IJwtTokenProvider.cs b/backend/src/Modules/Auth/Application/Abstractions/IJwtTokenProvider.cs
new file mode 100644
index 0000000..4cf048e
--- /dev/null
+++ b/backend/src/Modules/Auth/Application/Abstractions/IJwtTokenProvider.cs
@@ -0,0 +1,9 @@
+using Knot.Modules.Auth.Domain;
+
+namespace Knot.Modules.Auth.Application.Abstractions;
+
+public interface IJwtTokenProvider
+{
+ string Generate(User user);
+}
+
diff --git a/backend/src/Modules/Auth/Application/Auth/DTOs/AuthResponseDto.cs b/backend/src/Modules/Auth/Application/Auth/DTOs/AuthResponseDto.cs
new file mode 100644
index 0000000..4e28fd4
--- /dev/null
+++ b/backend/src/Modules/Auth/Application/Auth/DTOs/AuthResponseDto.cs
@@ -0,0 +1,21 @@
+using System;
+
+namespace Knot.Modules.Auth.Application.Auth.DTOs;
+
+public record AuthResponseDto(
+ string Token,
+ AuthUserDto User
+);
+
+public record AuthUserDto(
+ Guid Id,
+ string Username,
+ string DisplayName,
+ string? Email,
+ string? Bio,
+ string? Avatar,
+ DateTime? Birthday,
+ bool IsOnline,
+ DateTime CreatedAt
+);
+
diff --git a/backend/src/Modules/Auth/Application/Users/Auth/AuthResponseDto.cs b/backend/src/Modules/Auth/Application/Users/Auth/AuthResponseDto.cs
new file mode 100644
index 0000000..17f2752
--- /dev/null
+++ b/backend/src/Modules/Auth/Application/Users/Auth/AuthResponseDto.cs
@@ -0,0 +1,21 @@
+using System;
+
+namespace Knot.Modules.Auth.Application.Users.Auth;
+
+public record AuthResponseDto(
+ string Token,
+ AuthUserDto User
+);
+
+public record AuthUserDto(
+ Guid Id,
+ string Username,
+ string DisplayName,
+ string? Email,
+ string? Bio,
+ string? Avatar,
+ DateTime? Birthday,
+ bool IsOnline,
+ DateTime CreatedAt
+);
+
diff --git a/backend/src/Modules/Auth/Domain/AuthErrors.cs b/backend/src/Modules/Auth/Domain/AuthErrors.cs
new file mode 100644
index 0000000..8361373
--- /dev/null
+++ b/backend/src/Modules/Auth/Domain/AuthErrors.cs
@@ -0,0 +1,15 @@
+using Knot.Shared.Kernel;
+
+namespace Knot.Modules.Auth.Domain;
+
+public static class AuthErrors
+{
+ public static readonly Error FriendsNotFound = new Error("Friends.NotFound", "Friendship not found");
+ public static readonly Error FriendsSelf = new Error("Friends.Self", "Cannot add yourself");
+ public static readonly Error FriendsExists = new Error("Friends.Exists", "Friendship already exists");
+ public static readonly Error UserNotFound = new Error("User.NotFound", "User not found");
+ public static readonly Error IdentityInvalidCredentials = new Error("Identity.InvalidCredentials", "Неверное имя пользователя или пароль.");
+ public static readonly Error IdentityRegistrationDisabled = new Error("Identity.RegistrationDisabled", "Registration is disabled by the administrator.");
+ public static readonly Error IdentityUsernameNotUnique = new Error("Identity.UsernameNotUnique", "Это имя пользователя уже занято.");
+}
+
diff --git a/backend/src/Modules/Auth/Domain/IUserRepository.cs b/backend/src/Modules/Auth/Domain/IUserRepository.cs
new file mode 100644
index 0000000..bf25a49
--- /dev/null
+++ b/backend/src/Modules/Auth/Domain/IUserRepository.cs
@@ -0,0 +1,19 @@
+using Knot.Modules.Auth.Domain;
+
+namespace Knot.Modules.Auth.Domain;
+
+///
+/// Интерфейс репозитория для работы с пользователями.
+///
+public interface IUserRepository
+{
+ Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
+ Task> GetByIdsAsync(IEnumerable ids, CancellationToken cancellationToken = default);
+ Task GetByUsernameAsync(string username, CancellationToken cancellationToken = default);
+ Task IsUsernameUniqueAsync(string username, CancellationToken cancellationToken = default);
+ Task> SearchUsersAsync(string query, CancellationToken cancellationToken = default);
+ void Add(User user);
+ void Update(User user);
+ void Remove(User user);
+}
+
diff --git a/backend/src/Modules/Messaging/Infrastructure/Persistence/Mongo/MongoDbMapConfigurator.cs b/backend/src/Modules/Messaging/Infrastructure/Persistence/Mongo/MongoDbMapConfigurator.cs
index 97ed335..693f229 100644
--- a/backend/src/Modules/Messaging/Infrastructure/Persistence/Mongo/MongoDbMapConfigurator.cs
+++ b/backend/src/Modules/Messaging/Infrastructure/Persistence/Mongo/MongoDbMapConfigurator.cs
@@ -61,8 +61,8 @@ public static class MongoDbMapConfigurator
BsonClassMap.RegisterClassMap(cm =>
{
cm.AutoMap();
- cm.MapField("_options").SetElementName("Options");
- cm.MapField("_votes").SetElementName("Votes");
+ cm.MapProperty(c => c.Options).SetElementName("_options");
+ cm.MapProperty(c => c.Votes).SetElementName("_votes");
cm.SetDiscriminator("PollMessage");
});
diff --git a/backend/src/Modules/Profiles/Domain/IAvatarStorageService.cs b/backend/src/Modules/Profiles/Domain/IAvatarStorageService.cs
new file mode 100644
index 0000000..96f5192
--- /dev/null
+++ b/backend/src/Modules/Profiles/Domain/IAvatarStorageService.cs
@@ -0,0 +1,11 @@
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Knot.Modules.Profiles.Domain;
+
+public interface IAvatarStorageService
+{
+ Task UploadAsync(Stream content, string fileName, string contentType, CancellationToken ct = default);
+ Task DeleteAsync(string fileId, CancellationToken ct = default);
+}
diff --git a/backend/src/Modules/Profiles/Domain/IProfileRepository.cs b/backend/src/Modules/Profiles/Domain/IProfileRepository.cs
new file mode 100644
index 0000000..74f365a
--- /dev/null
+++ b/backend/src/Modules/Profiles/Domain/IProfileRepository.cs
@@ -0,0 +1,15 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Knot.Modules.Profiles.Domain;
+
+public interface IProfileRepository
+{
+ Task GetByIdAsync(Guid id, CancellationToken ct = default);
+ Task GetByUsernameAsync(string username, CancellationToken ct = default);
+ Task AddAsync(ProfileDocument profile, CancellationToken ct = default);
+ Task UpdateAsync(ProfileDocument profile, CancellationToken ct = default);
+ Task> SearchAsync(string query, CancellationToken ct = default);
+}
diff --git a/backend/src/Modules/Profiles/Domain/IProfilesUnitOfWork.cs b/backend/src/Modules/Profiles/Domain/IProfilesUnitOfWork.cs
new file mode 100644
index 0000000..b18ed41
--- /dev/null
+++ b/backend/src/Modules/Profiles/Domain/IProfilesUnitOfWork.cs
@@ -0,0 +1,9 @@
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Knot.Modules.Profiles.Domain;
+
+public interface IProfilesUnitOfWork
+{
+ Task SaveChangesAsync(CancellationToken ct = default);
+}
diff --git a/backend/src/Modules/Profiles/Domain/ProfilesErrors.cs b/backend/src/Modules/Profiles/Domain/ProfilesErrors.cs
new file mode 100644
index 0000000..23bc1be
--- /dev/null
+++ b/backend/src/Modules/Profiles/Domain/ProfilesErrors.cs
@@ -0,0 +1,10 @@
+namespace Knot.Modules.Profiles.Domain;
+using Knot.Shared.Kernel;
+public static class ProfilesErrors
+{
+ public static readonly Error ProfileNotFound = new("Profile.NotFound", "Profile not found");
+}
+public static class IdentityErrors
+{
+ public static readonly Error UserNotFound = new("Profile.NotFound", "Profile not found");
+}
diff --git a/backend/src/Modules/Settings/Application/Settings/Abstractions/ISettingsService.cs b/backend/src/Modules/Settings/Application/Settings/Abstractions/ISettingsService.cs
new file mode 100644
index 0000000..46cef1d
--- /dev/null
+++ b/backend/src/Modules/Settings/Application/Settings/Abstractions/ISettingsService.cs
@@ -0,0 +1,52 @@
+using System.Threading;
+using System.Threading.Tasks;
+using Knot.Modules.Settings.Application.Settings.DTOs;
+
+namespace Knot.Modules.Settings.Application.Settings.Abstractions;
+
+public interface ISettingsService
+{
+ Task GetSettingsAsync(CancellationToken cancellationToken = default);
+ Task UpdateSettingsAsync(SystemSettingsDto settings, CancellationToken cancellationToken = default);
+ SystemSettingsDto Current { get; }
+}
+
+public interface ISystemSettings
+{
+ SystemConfig Current { get; }
+}
+
+public interface IStoriesSettings
+{
+ StoriesConfig Current { get; }
+}
+
+public interface IChatsSettings
+{
+ ChatsConfig Current { get; }
+}
+
+public interface IMessagesSettings
+{
+ MessagesConfig Current { get; }
+}
+
+public interface IWebRtcSettings
+{
+ WebRtcConfig Current { get; }
+}
+
+public interface IKlipySettings
+{
+ KlipyConfig Current { get; }
+}
+
+public interface IImportSettings
+{
+ ImportConfig Current { get; }
+}
+
+public interface IFederationSettings
+{
+ FederationConfig Current { get; }
+}
diff --git a/backend/src/Modules/Settings/Application/Settings/DTOs/PublicConfigDto.cs b/backend/src/Modules/Settings/Application/Settings/DTOs/PublicConfigDto.cs
new file mode 100644
index 0000000..7fc200a
--- /dev/null
+++ b/backend/src/Modules/Settings/Application/Settings/DTOs/PublicConfigDto.cs
@@ -0,0 +1,159 @@
+using System.Collections.Generic;
+using Knot.Modules.Settings.Application.Settings.DTOs;
+
+namespace Knot.Modules.Settings.Application.Settings.DTOs;
+
+public record PublicConfigDto
+{
+ public SystemConfigDto System { get; init; } = new();
+ public StoriesConfigDto Stories { get; init; } = new();
+ public ChatsConfigDto Chats { get; init; } = new();
+ public MessagesConfigDto Messages { get; init; } = new();
+ public WebRtcConfigDto WebRtc { get; init; } = new();
+ public KlipyConfigDto Klipy { get; init; } = new();
+ public ImportConfigDto Import { get; init; } = new();
+ public FederationConfigDto Federation { get; init; } = new();
+
+ public static PublicConfigDto FromSettings(SystemSettingsDto settings)
+ {
+ return new PublicConfigDto
+ {
+ System = new SystemConfigDto
+ {
+ DomainUrl = settings.System.DomainUrl,
+ EnableRegistration = settings.System.EnableRegistration
+ },
+ Stories = new StoriesConfigDto
+ {
+ Enabled = settings.Stories.Enabled,
+ MaxStoriesPerPeriod = settings.Stories.MaxStoriesPerPeriod,
+ StoryLifetimeHours = settings.Stories.StoryLifetimeHours,
+ TextStoriesEnabled = settings.Stories.TextStoriesEnabled,
+ TextStoryDurationSeconds = settings.Stories.TextStoryDurationSeconds,
+ MediaStoryMaxDurationSeconds = settings.Stories.MediaStoryMaxDurationSeconds,
+ MaxMediaSizeBytes = settings.Stories.MaxMediaSizeBytes
+ },
+ Chats = new ChatsConfigDto
+ {
+ SupportGroups = settings.Chats.SupportGroups,
+ MaxGroupParticipants = settings.Chats.MaxGroupParticipants,
+ AllowChatToGroupConversion = settings.Chats.AllowChatToGroupConversion,
+ EnableFolders = settings.Chats.EnableFolders
+ },
+ Messages = new MessagesConfigDto
+ {
+ DailyMessageLimitPerUser = settings.Messages.DailyMessageLimitPerUser,
+ ChatMessageLimit = settings.Messages.ChatMessageLimit,
+ AllowMedia = settings.Messages.AllowMedia,
+ MaxMediaSizeBytes = settings.Messages.MaxMediaSizeBytes,
+ AllowedMediaTypes = settings.Messages.AllowedMediaTypes,
+ AllowVoiceMessages = settings.Messages.AllowVoiceMessages,
+ AllowForwarding = settings.Messages.AllowForwarding,
+ AllowReactions = settings.Messages.AllowReactions,
+ AllowReplies = settings.Messages.AllowReplies,
+ AllowQuoting = settings.Messages.AllowQuoting,
+ AllowMessageDeletion = settings.Messages.AllowMessageDeletion,
+ ForbidCopying = settings.Messages.ForbidCopying,
+ AllowLinks = settings.Messages.AllowLinks,
+ AllowPolls = settings.Messages.AllowPolls,
+ AllowPinning = settings.Messages.AllowPinning
+ },
+ WebRtc = new WebRtcConfigDto
+ {
+ Enabled = settings.WebRtc.Enabled,
+ EnableVoiceCalls = settings.WebRtc.EnableVoiceCalls,
+ EnableVideoCalls = settings.WebRtc.EnableVideoCalls,
+ EnableScreenSharing = settings.WebRtc.EnableScreenSharing,
+ TurnHost = settings.WebRtc.TurnHost,
+ TurnPort = settings.WebRtc.TurnPort
+ },
+ Klipy = new KlipyConfigDto
+ {
+ Enabled = settings.Klipy.Enabled,
+ AppName = settings.Klipy.AppName
+ },
+ Import = new ImportConfigDto
+ {
+ Enabled = settings.Import.EnableTelegramImport
+ },
+ Federation = new FederationConfigDto
+ {
+ Enabled = settings.Federation.Enabled,
+ ServerDescription = settings.Federation.ServerDescription,
+ AllowedDomains = settings.Federation.AllowedDomains
+ }
+ };
+ }
+}
+
+public record SystemConfigDto
+{
+ public string DomainUrl { get; init; } = string.Empty;
+ public bool EnableRegistration { get; init; }
+}
+
+public record StoriesConfigDto
+{
+ public bool Enabled { get; init; }
+ public int MaxStoriesPerPeriod { get; init; }
+ public int StoryLifetimeHours { get; init; }
+ public bool TextStoriesEnabled { get; init; }
+ public int TextStoryDurationSeconds { get; init; }
+ public int MediaStoryMaxDurationSeconds { get; init; }
+ public int MaxMediaSizeBytes { get; init; }
+}
+
+public record ChatsConfigDto
+{
+ public bool SupportGroups { get; init; }
+ public int MaxGroupParticipants { get; init; }
+ public bool AllowChatToGroupConversion { get; init; }
+ public bool EnableFolders { get; init; }
+}
+
+public record MessagesConfigDto
+{
+ public int DailyMessageLimitPerUser { get; init; }
+ public int ChatMessageLimit { get; init; }
+ public bool AllowMedia { get; init; }
+ public int MaxMediaSizeBytes { get; init; }
+ public List AllowedMediaTypes { get; init; } = new();
+ public bool AllowVoiceMessages { get; init; }
+ public bool AllowForwarding { get; init; }
+ public bool AllowReactions { get; init; }
+ public bool AllowReplies { get; init; }
+ public bool AllowQuoting { get; init; }
+ public bool AllowMessageDeletion { get; init; }
+ public bool ForbidCopying { get; init; }
+ public bool AllowLinks { get; init; }
+ public bool AllowPolls { get; init; }
+ public bool AllowPinning { get; init; }
+}
+
+public record WebRtcConfigDto
+{
+ public bool Enabled { get; init; }
+ public bool EnableVoiceCalls { get; init; }
+ public bool EnableVideoCalls { get; init; }
+ public bool EnableScreenSharing { get; init; }
+ public string TurnHost { get; init; } = string.Empty;
+ public int TurnPort { get; init; }
+}
+
+public record KlipyConfigDto
+{
+ public bool Enabled { get; init; }
+ public string AppName { get; init; } = string.Empty;
+}
+
+public record ImportConfigDto
+{
+ public bool Enabled { get; init; }
+}
+
+public record FederationConfigDto
+{
+ public bool Enabled { get; init; }
+ public string ServerDescription { get; init; } = string.Empty;
+ public List AllowedDomains { get; init; } = new();
+}
diff --git a/backend/src/Modules/Settings/Application/Settings/DTOs/SystemSettingsDto.cs b/backend/src/Modules/Settings/Application/Settings/DTOs/SystemSettingsDto.cs
new file mode 100644
index 0000000..d34cfaf
--- /dev/null
+++ b/backend/src/Modules/Settings/Application/Settings/DTOs/SystemSettingsDto.cs
@@ -0,0 +1,112 @@
+using System.Collections.Generic;
+
+namespace Knot.Modules.Settings.Application.Settings.DTOs;
+
+public class SystemConfig
+{
+ public string ServerTimezone { get; set; } = "UTC";
+ public string DomainUrl { get; set; } = "https://example.com";
+ public string AdminRoute { get; set; } = "admin";
+ public bool EnableRegistration { get; set; } = true;
+}
+
+public class StoriesConfig
+{
+ public bool Enabled { get; set; } = true;
+ public int MaxStoriesPerPeriod { get; set; } = 5;
+ public int StoryLifetimeHours { get; set; } = 24;
+ public bool TextStoriesEnabled { get; set; } = true;
+ public int TextStoryDurationSeconds { get; set; } = 15;
+ public int MediaStoryMaxDurationSeconds { get; set; } = 30;
+ public int MaxMediaSizeBytes { get; set; } = 15 * 1024 * 1024;
+}
+
+public class ChatsConfig
+{
+ public bool SupportGroups { get; set; } = true;
+ public int MaxGroupParticipants { get; set; } = 200000;
+ public bool AutoCleanChats { get; set; } = false;
+ public bool AllowChatToGroupConversion { get; set; } = true;
+ public bool EnableFolders { get; set; } = true;
+}
+
+public class MessagesConfig
+{
+ public int DailyMessageLimitPerUser { get; set; } = 0;
+ public int ChatMessageLimit { get; set; } = 0;
+ public bool AllowMedia { get; set; } = true;
+ public int MaxMediaSizeBytes { get; set; } = 50 * 1024 * 1024;
+ public List AllowedMediaTypes { get; set; } = new() { "image/jpeg", "image/png", "video/mp4", "image/gif" };
+ public bool AllowVoiceMessages { get; set; } = true;
+ public bool AllowForwarding { get; set; } = true;
+ public bool AllowReactions { get; set; } = true;
+ public bool AllowReplies { get; set; } = true;
+ public bool AllowQuoting { get; set; } = true;
+ public bool AllowMessageDeletion { get; set; } = true;
+ public bool ForbidCopying { get; set; } = false;
+ public bool AllowLinks { get; set; } = true;
+ public bool AllowPolls { get; set; } = true;
+ public bool AllowPinning { get; set; } = true;
+}
+
+public class WebRtcConfig
+{
+ public bool Enabled { get; set; } = false;
+ public bool EnableVoiceCalls { get; set; } = true;
+ public bool EnableVideoCalls { get; set; } = true;
+ public bool EnableScreenSharing { get; set; } = true;
+ public string TurnHost { get; set; } = string.Empty;
+ public int TurnPort { get; set; } = 3478;
+ public string TurnUser { get; set; } = string.Empty;
+ public string TurnSecret { get; set; } = string.Empty;
+}
+
+public class KlipyConfig
+{
+ public bool Enabled { get; set; } = false;
+ public string ApiKey { get; set; } = string.Empty;
+ public string AppName { get; set; } = string.Empty;
+}
+
+public class ImportConfig
+{
+ public bool EnableTelegramImport { get; set; } = false;
+}
+
+public class FederationDomainConfig
+{
+ public string Domain { get; set; } = string.Empty;
+ public bool IsEnabled { get; set; } = true;
+ public string? PublicKey { get; set; }
+ public RemoteCapabilities? Capabilities { get; set; }
+}
+
+public class RemoteCapabilities
+{
+ public bool AllowMedia { get; set; }
+ public bool AllowPolls { get; set; }
+ public bool AllowVoiceMessages { get; set; }
+ public bool AllowVideoCalls { get; set; }
+ public bool AllowScreenSharing { get; set; }
+}
+
+public class FederationConfig
+{
+ public bool Enabled { get; set; } = false;
+ public string ServerDescription { get; set; } = string.Empty;
+ public string? PrivateKey { get; set; }
+ public string? PublicKey { get; set; }
+ public List AllowedDomains { get; set; } = new();
+}
+
+public class SystemSettingsDto
+{
+ public SystemConfig System { get; set; } = new();
+ public StoriesConfig Stories { get; set; } = new();
+ public ChatsConfig Chats { get; set; } = new();
+ public MessagesConfig Messages { get; set; } = new();
+ public WebRtcConfig WebRtc { get; set; } = new();
+ public KlipyConfig Klipy { get; set; } = new();
+ public ImportConfig Import { get; set; } = new();
+ public FederationConfig Federation { get; set; } = new();
+}
diff --git a/backend/src/Modules/Stories/Application/Abstractions/IKlipyClient.cs b/backend/src/Modules/Stories/Application/Abstractions/IKlipyClient.cs
new file mode 100644
index 0000000..9a06e0d
--- /dev/null
+++ b/backend/src/Modules/Stories/Application/Abstractions/IKlipyClient.cs
@@ -0,0 +1,11 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Knot.Modules.Stories.Application.Abstractions;
+
+public interface IKlipyClient
+{
+ Task TestConnectionAsync(string apiKey, CancellationToken ct = default);
+ Task> SearchVideosAsync(string apiKey, string query, int limit, CancellationToken ct = default);
+}
diff --git a/backend/src/Modules/Stories/Application/Abstractions/IStoriesDbContext.cs b/backend/src/Modules/Stories/Application/Abstractions/IStoriesDbContext.cs
new file mode 100644
index 0000000..777b5f5
--- /dev/null
+++ b/backend/src/Modules/Stories/Application/Abstractions/IStoriesDbContext.cs
@@ -0,0 +1,21 @@
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore;
+using Knot.Modules.Stories.Domain;
+using Knot.Modules.Relations.Domain;
+
+namespace Knot.Modules.Stories.Application.Abstractions;
+
+public interface IStoriesDbContext
+{
+ DbSet Stories { get; }
+ DbSet Friendships { get; }
+ DbSet StoryViewers { get; }
+ DbSet Set() where TEntity : class;
+ Task SaveChangesAsync(CancellationToken cancellationToken);
+}
+
+public interface IStoriesUnitOfWork
+{
+ Task SaveChangesAsync(CancellationToken cancellationToken = default);
+}
diff --git a/backend/src/Modules/Stories/Infrastructure/External/KlipyClient.cs b/backend/src/Modules/Stories/Infrastructure/External/KlipyClient.cs
new file mode 100644
index 0000000..aad4588
--- /dev/null
+++ b/backend/src/Modules/Stories/Infrastructure/External/KlipyClient.cs
@@ -0,0 +1,40 @@
+using System;
+using System.Collections.Generic;
+using System.Net.Http;
+using Knot.Modules.Stories.Application.Abstractions;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Knot.Modules.Stories.Infrastructure.External;
+
+public sealed class KlipyClient : IKlipyClient
+{
+ private readonly HttpClient _httpClient;
+
+ public KlipyClient(HttpClient httpClient)
+ {
+ _httpClient = httpClient;
+ }
+
+ public async Task TestConnectionAsync(string apiKey, CancellationToken ct = default)
+ {
+ try
+ {
+ var request = new HttpRequestMessage(HttpMethod.Get, "https://api.klipy.co/v1/trending?limit=1");
+ request.Headers.Add("X-API-KEY", apiKey);
+
+ using var response = await _httpClient.SendAsync(request, ct);
+ return response.IsSuccessStatusCode;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ public async Task> SearchVideosAsync(string apiKey, string query, int limit, CancellationToken ct = default)
+ {
+ // В реальном проекте: десериализация ответа от Klipy API
+ return new List();
+ }
+}
diff --git a/client-web/src/modules/stories/infrastructure/storyApi.ts b/client-web/src/modules/stories/infrastructure/storyApi.ts
index 20c196b..8bdfe72 100644
--- a/client-web/src/modules/stories/infrastructure/storyApi.ts
+++ b/client-web/src/modules/stories/infrastructure/storyApi.ts
@@ -47,9 +47,8 @@ export class StoryApi {
}
static async removeStoryReaction(storyId: string, emoji: string) {
- return httpClient.request<{ message: string }>(`/stories/${storyId}/reaction`, {
- method: 'DELETE',
- body: JSON.stringify({ emoji }),
+ return httpClient.request<{ message: string }>(`/stories/${storyId}/reaction/delete?emoji=${encodeURIComponent(emoji)}`, {
+ method: 'POST',
});
}