diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index c383e46..b0d56a7 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -10,21 +10,24 @@ 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 Govor.API.Tests/Govor.API.Tests.csproj + + 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/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ef71324 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src + +# .sln .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/ +COPY Govor.Contracts/*.csproj ./Govor.Contracts/ + +RUN dotnet restore Govor.API/Govor.API.csproj + +# +COPY . . +WORKDIR /src/Govor.API +RUN dotnet publish -c Release -o /app/publish + +# Runtime image +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final +WORKDIR /app +COPY --from=build /app/publish . + +EXPOSE 8080 + +ENTRYPOINT ["dotnet", "Govor.API.dll"] \ No newline at end of file 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/Controllers/SessionControllerTests.cs b/Govor.API.Tests/Controllers/SessionControllerTests.cs new file mode 100644 index 0000000..f3190db --- /dev/null +++ b/Govor.API.Tests/Controllers/SessionControllerTests.cs @@ -0,0 +1,389 @@ +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.Users; +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 _currentUserSessionServiceMock; + private Mock _userSessionReaderMock; + private Mock _userSessionRevokerMock; + private Mock _mapperMock; + private SessionController _controller; + + [SetUp] + public void SetUp() + { + _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() + { + // 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()); + } + #endregion + + #region CloseSession + [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()); + } + #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() + { + // 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()); + } + #endregion + + [TearDown] + public void TearDown() + { + _controller?.Dispose(); + } +} \ 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 139f439..26fdd64 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,14 @@ - - + + - - - + + + + @@ -25,8 +26,7 @@ - - + + - diff --git a/Govor.API.Tests/Hubs/PresenceHubTests.cs b/Govor.API.Tests/Hubs/PresenceHubTests.cs new file mode 100644 index 0000000..032a1e6 --- /dev/null +++ b/Govor.API.Tests/Hubs/PresenceHubTests.cs @@ -0,0 +1,196 @@ +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.Mvc; +using Microsoft.AspNetCore.SignalR; +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 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)); + } + } + + [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); + } + + [TearDown] + public void TearDown() + { + _hub?.Dispose(); + } +} \ No newline at end of file diff --git a/Govor.API.Tests/IntegrationTests/Controllers/AuthControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/AuthControllerTests.cs index 82808d2..d216655 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.API.Controllers.Authentication; 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 ); } @@ -46,10 +50,19 @@ 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(); + + _invitesServiceMock.Setup(s => s.ValidateAsync(request.InviteLink)).ReturnsAsync(invitation); - _invitesServiceMock.Setup(s => s.Validate(request.InviteLink)).Returns(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); @@ -57,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] @@ -80,14 +96,14 @@ 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); // 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.")); } @@ -97,7 +113,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 +132,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")); @@ -136,16 +152,26 @@ public class AuthControllerTests { // Arrange var loginRequest = _fixture.Create(); - var token = _fixture.Create(); - _accountServiceMock.Setup(l => l.LoginAsync(loginRequest.Name, loginRequest.Password)).ReturnsAsync(token); + var token = _fixture.Create(); + + 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); // 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.Tests/IntegrationTests/Controllers/ChatLoadControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/ChatLoadControllerTests.cs new file mode 100644 index 0000000..bc5fb08 --- /dev/null +++ b/Govor.API.Tests/IntegrationTests/Controllers/ChatLoadControllerTests.cs @@ -0,0 +1,266 @@ +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 Govor.Data.Repositories.Exceptions; +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))); + } + + [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()); + } + + [TearDown] + public void TearDown() + { + _controller?.Dispose(); + } +} \ No newline at end of file diff --git a/Govor.API.Tests/IntegrationTests/Controllers/FriendsControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/FriendsControllerTests.cs deleted file mode 100644 index 124d67d..0000000 --- a/Govor.API.Tests/IntegrationTests/Controllers/FriendsControllerTests.cs +++ /dev/null @@ -1,115 +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)); - } - - - - [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..1d2b6c1 --- /dev/null +++ b/Govor.API.Tests/IntegrationTests/Controllers/FriendsRequestQueryControllerTests.cs @@ -0,0 +1,208 @@ +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)); + } + + [TearDown] + public void TearDown() + { + _controller?.Dispose(); + } +} \ 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..6d712fb --- /dev/null +++ b/Govor.API.Tests/IntegrationTests/Controllers/FriendshipControllerTests.cs @@ -0,0 +1,177 @@ +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_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.Tests/IntegrationTests/Controllers/MediaControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/MediaControllerTests.cs new file mode 100644 index 0000000..0cf8f7e --- /dev/null +++ b/Govor.API.Tests/IntegrationTests/Controllers/MediaControllerTests.cs @@ -0,0 +1,362 @@ +using System.Text; +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 Mock _mockAccesser; + 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>(); + _mockAccesser = new Mock(); + _mockMedia = new Mock(); + + _currentUserMock.Setup(f => f.GetCurrentUserId()).Returns(_userId); + + _controller = new MediaController( + _loggerMock.Object, + _mockMedia.Object, + _mockAccesser.Object, + _currentUserMock.Object); + } + + // Tests 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 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)); + } + + [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 forbidResult = result as ForbidResult; + Assert.That(forbidResult.AuthenticationSchemes.First(), 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() + { + _controller.Dispose(); + } +} \ No newline at end of file diff --git a/Govor.API.Tests/IntegrationTests/Controllers/OnlinePingingControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/OnlinePingingControllerTests.cs new file mode 100644 index 0000000..0133572 --- /dev/null +++ b/Govor.API.Tests/IntegrationTests/Controllers/OnlinePingingControllerTests.cs @@ -0,0 +1,138 @@ +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()); + } + + + [TearDown] + public void TearDown() + { + _controller?.Dispose(); + } +} + +// 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/IntegrationTests/Controllers/ProfileControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/ProfileControllerTests.cs new file mode 100644 index 0000000..323d590 --- /dev/null +++ b/Govor.API.Tests/IntegrationTests/Controllers/ProfileControllerTests.cs @@ -0,0 +1,284 @@ +using System.Text; +using AutoMapper; +using Govor.API.Controllers; +using Govor.API.Hubs; +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.Contracts.Requests; +using Govor.Core.Models; +using Govor.Core.Models.Messages; +using Govor.Data.Repositories.Exceptions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using Moq; +using Newtonsoft.Json.Linq; + +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> _mockProfileHubContext = null!; + private Mock _mockClients = null!; + private Mock _mockClientProxy = 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(); + + _mockProfileHubContext = new Mock>(); + _mockClients = new Mock(); + _mockClientProxy = new Mock(); + + _mockProfileHubContext.Setup(x => x.Clients).Returns(_mockClients.Object); + _mockClients.Setup(x => x.All).Returns(_mockClientProxy.Object); + _mockClientProxy.Setup(x => x.SendCoreAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + _controller = new ProfileController( + _mockLogger.Object, + _mockMediaService.Object, + _mockProfileService.Object, + _mockCurrentUserService.Object, + _mockProfileHubContext.Object, + _mockMapper.Object); + + _userId = Guid.NewGuid(); + _mockCurrentUserService.Setup(x => x.GetCurrentUserId()).Returns(_userId); + } + + [Test] + public async Task UploadAvatar_ReturnsBadRequest_WhenFileIsEmpty() + { + // Arrange + var request = new AvatarUploadRequest + { + FromFile = null + }; + + // Act + var result = await _controller.UploadAvatar(request); + + // Assert + Assert.That(result, Is.InstanceOf()); + var badRequestResult = (BadRequestObjectResult)result; + Assert.That(badRequestResult.Value, Is.EqualTo("File is empty.")); + + _mockMediaService.Verify(s => s.UploadMediaAsync(It.IsAny()), Times.Never); + } + + [Test] + public async Task UploadAvatar_ReturnsBadRequest_WhenFileLengthIsZero() + { + // Arrange + var mockFile = new Mock(); + mockFile.Setup(f => f.Length).Returns(0); + + var request = new AvatarUploadRequest + { + FromFile = mockFile.Object + }; + + // Act + var result = await _controller.UploadAvatar(request); + + // Assert + Assert.That(result, Is.InstanceOf()); + var badRequestResult = (BadRequestObjectResult)result; + Assert.That(badRequestResult.Value, Is.EqualTo("File is empty.")); + + _mockMediaService.Verify(s => s.UploadMediaAsync(It.IsAny()), Times.Never); + } + + [Test] + public async Task UploadAvatar_ReturnsOkAndPerformsAllActions_WhenFileIsValid() + { + // Arrange + var mediaId = Guid.NewGuid(); + var expectedMediaInfo = new MediaUploadResult(mediaId, $"/media/{mediaId}"); + + var fileName = "test_avatar.jpg"; + var fileContent = "This is a test image content"; + var stream = new MemoryStream(Encoding.UTF8.GetBytes(fileContent)); + + var mockFile = new Mock(); + mockFile.Setup(f => f.FileName).Returns(fileName); + mockFile.Setup(f => f.Length).Returns(stream.Length); + mockFile.Setup(f => f.OpenReadStream()).Returns(stream); + mockFile.Setup(f => f.CopyToAsync(It.IsAny(), It.IsAny())) + .Callback((s, t) => stream.CopyTo(s)); // Для ReadFileAsync + + var request = new AvatarUploadRequest + { + FromFile = mockFile.Object, + Type = MediaType.Image, + MimeType = "image/jpeg" + }; + + _mockMediaService.Setup(s => s.UploadMediaAsync(It.IsAny())) + .ReturnsAsync(expectedMediaInfo); + + _mockProfileService.Setup(s => s.SetNewIcon(_userId, mediaId)) + .Returns(Task.CompletedTask); + + // Act + var result = await _controller.UploadAvatar(request); + + // Assert + Assert.That(result, Is.InstanceOf()); + var okResult = (OkObjectResult)result; + Assert.That(okResult.StatusCode, Is.EqualTo(200)); + Assert.That(okResult.Value, Is.EqualTo(expectedMediaInfo)); + + mockFile.Verify(f => f.CopyToAsync(It.IsAny(), It.IsAny()), Times.Once); + + _mockMediaService.Verify(s => s.UploadMediaAsync(It.Is(m => + m.OwnerId == _userId && + m.OwnerType == MediaOwnerType.Avatar && + m.FileName == fileName + )), Times.Once); + + _mockProfileService.Verify(s => s.SetNewIcon(_userId, mediaId), Times.Once); + + _mockClientProxy.Verify( + c => c.SendCoreAsync("AvatarUpdated", It.Is( + args => args.Length == 1 && + JObject.FromObject(args[0]!).Value("userId") == _userId && + JObject.FromObject(args[0]!).Value("iconId") == mediaId + ), It.IsAny()), + Times.Once, + "Hub SendAsync must be called to notify clients." + ); + } + + [Test] + public async Task UploadAvatar_ReturnsInternalServerError_WhenMediaServiceFails() + { + // Arrange + var mockFile = new Mock(); + mockFile.Setup(f => f.Length).Returns(100); + mockFile.Setup(f => f.OpenReadStream()).Returns(new MemoryStream(Encoding.UTF8.GetBytes("test"))); + + var request = new AvatarUploadRequest { FromFile = mockFile.Object }; + + _mockMediaService.Setup(s => s.UploadMediaAsync(It.IsAny())) + .ThrowsAsync(new System.Exception("Simulated upload failure")); + + // Act + var result = await _controller.UploadAvatar(request); + + // Assert + Assert.That(result, Is.InstanceOf()); + var objectResult = (ObjectResult)result; + Assert.That(objectResult.StatusCode, Is.EqualTo(500)); + + _mockProfileService.Verify(s => s.SetNewIcon(It.IsAny(), It.IsAny()), Times.Never); + + _mockClientProxy.Verify( + c => c.SendCoreAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never, + "The Hub should not be called in case of a service error." + ); + } + + [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.DownloadProfile(); + + // 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_ReturnsForbid_WhenUnauthorizedAccessExceptionThrown() + { + // Arrange + _mockProfileService.Setup(s => s.GetUserProfileAsync(_userId)) + .ThrowsAsync(new UnauthorizedAccessException("Access denied")); + + // Act + var result = await _controller.DownloadProfile(); + + // Assert + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task DowloadProfile_ReturnsBadRequest_WhenNotFoundExceptionThrown() + { + // Arrange + _mockProfileService.Setup(s => s.GetUserProfileAsync(_userId)) + .ThrowsAsync(new NotFoundException("Cannot find profile")); + + // Act + var result = await _controller.DownloadProfile(); + + // 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_Returns500_WhenUnexpectedExceptionThrown() + { + // Arrange + _mockProfileService.Setup(s => s.GetUserProfileAsync(_userId)) + .ThrowsAsync(new Exception("Unexpected error")); + + // Act + var result = await _controller.DownloadProfile(); + + // 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.Tests/IntegrationTests/Controllers/RefreshControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/RefreshControllerTests.cs new file mode 100644 index 0000000..31c41f8 --- /dev/null +++ b/Govor.API.Tests/IntegrationTests/Controllers/RefreshControllerTests.cs @@ -0,0 +1,124 @@ +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()); + } + + + [TearDown] + public void TearDown() + { + _controller?.Dispose(); + } +} \ 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..01ce8af --- /dev/null +++ b/Govor.API.Tests/IntegrationTests/Hubs/ChatsHubTests.cs @@ -0,0 +1,55 @@ +using AutoFixture; +using Govor.API.Common.SignalR.Helpers; +using Govor.API.Hubs; +using Govor.Application.Interfaces; +using Govor.Application.Interfaces.Messages; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Govor.API.Tests.IntegrationTests.Hubs; + +[TestFixture] +public class ChatsHubTests +{ + private Mock> _loggerMock; + private Mock _messageServiceMock; + private Mock _userGroupsServiceMock; + private Mock _hubUserAccessorMock; + 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(); + _userGroupsServiceMock = new Mock(); + _loggerMock = new Mock>(); + _hubUserAccessorMock = new Mock(); + + _chatsHub = new ChatsHub( + _loggerMock.Object, + _messageServiceMock.Object, + _userGroupsServiceMock.Object, + _hubUserAccessorMock.Object + ); + } + + // Test for Send action + [Test] + public void SendMessage_Success() + { + + } + + + [TearDown] + public void TearDown() + { + _chatsHub?.Dispose(); + } +} \ No newline at end of file diff --git a/Govor.API.Tests/IntegrationTests/Hubs/FriendsHubTests.cs b/Govor.API.Tests/IntegrationTests/Hubs/FriendsHubTests.cs new file mode 100644 index 0000000..e0ee94d --- /dev/null +++ b/Govor.API.Tests/IntegrationTests/Hubs/FriendsHubTests.cs @@ -0,0 +1,344 @@ +using AutoFixture; +using AutoMapper; +using Govor.API.Common.SignalR.Helpers; +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; + +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 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.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, + _mapperMock.Object) + { + Clients = _clientsMock.Object + }; + } + + // Tests for SendRequest action + [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); + + // 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(dto)), + 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.GetUserId(It.IsAny(), It.IsAny())) + .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 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(friendship.Id); + + // Assert + Assert.That(result.Status, Is.EqualTo(HubResultStatus.Success)); + _friendRequestServiceMock.Verify(s => s.AcceptAsync(friendship.Id, _userId), Times.Once); + + _clientProxyMock.Verify(c => c.SendCoreAsync( + "FriendRequestAccepted", + It.Is(o => o.Length == 1 && o[0] == dto), + 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 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(friendship.Id); + + // Assert + Assert.That(result.Status, Is.EqualTo(HubResultStatus.Success)); + _friendRequestServiceMock.Verify(s => s.RejectAsync(friendship.Id, _userId), Times.Once); + + _clientProxyMock.Verify(c => c.SendCoreAsync( + "FriendRequestRejected", + It.Is(o => o[0]!.Equals(dto)), + 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!")); + } + + [TearDown] + public void TearDown() + { + _hub?.Dispose(); + } +} 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.Tests/UnitTests/Infrastructure/Validators/ChatGroupValidatorTests.cs b/Govor.API.Tests/UnitTests/Infrastructure/Validators/ChatGroupValidatorTests.cs new file mode 100644 index 0000000..42986c0 --- /dev/null +++ b/Govor.API.Tests/UnitTests/Infrastructure/Validators/ChatGroupValidatorTests.cs @@ -0,0 +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 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/PrivateChatValidatorTests.cs b/Govor.API.Tests/UnitTests/Infrastructure/Validators/PrivateChatValidatorTests.cs new file mode 100644 index 0000000..43d54d1 --- /dev/null +++ b/Govor.API.Tests/UnitTests/Infrastructure/Validators/PrivateChatValidatorTests.cs @@ -0,0 +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/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.Tests/UnitTests/Services/Authentication/JwtServiceTests.cs b/Govor.API.Tests/UnitTests/Services/Authentication/JwtServiceTests.cs deleted file mode 100644 index e33d71e..0000000 --- a/Govor.API.Tests/UnitTests/Services/Authentication/JwtServiceTests.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.IdentityModel.Tokens.Jwt; -using AutoFixture; -using Govor.API.Services.Authentication.Interfaces; -using Govor.Application.Services; -using Govor.Core.Models; -using Microsoft.Extensions.Options; -using Moq; - -namespace Govor.API.Tests.UnitTests.Services.Authentication; - -[TestFixture] -public class JwtServiceTests -{ - private Fixture _fixture; - private Mock> _jwtOptionsMock; - private Mock _invitesServiceMock; - private IJwtService _jwtService; - - private JwtOption _testJwtOptions; - - [SetUp] - public void SetUp() - { - _fixture = new Fixture(); - _fixture.Behaviors.OfType().ToList().ForEach(b => _fixture.Behaviors.Remove(b)); - _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); - - _testJwtOptions = new JwtOption - { - SecretKeу = "THIS IS A TEST SECRET KEY THAT IS LONG ENOUGH", // Ensure key size is sufficient for HMACSHA256 - Hours = 1 - }; - - _jwtOptionsMock = new Mock>(); - _jwtOptionsMock.Setup(o => o.Value).Returns(_testJwtOptions); - - _invitesServiceMock = new Mock(); - - _jwtService = new JwtService(_jwtOptionsMock.Object, _invitesServiceMock.Object); - } - - [Test] - public void GenerateJwtToken_ShouldReturnValidJwtString() - { - // Arrange - var user = _fixture.Create(); - var expectedRole = "User"; - _invitesServiceMock.Setup(s => s.GetRole(user)).Returns(Task.FromResult(expectedRole)); - // Act - var tokenString = _jwtService.GenerateJwtToken(user); - - // 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)); - } -} \ No newline at end of file diff --git a/Govor.API.Tests/UnitTests/Services/FriendsServiceTests.cs b/Govor.API.Tests/UnitTests/Services/FriendsServiceTests.cs deleted file mode 100644 index e1e77b7..0000000 --- a/Govor.API.Tests/UnitTests/Services/FriendsServiceTests.cs +++ /dev/null @@ -1,358 +0,0 @@ -using AutoFixture; -using Govor.Application.Exceptions.FriendsService; -using Govor.Application.Interfaces; -using Govor.Application.Services; -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; - -[TestFixture] -public class FriendsServiceTests -{ - private Fixture _fixture; - private Mock _usersRepositoryMock; - private Mock _friendshipsRepositoryMock; - private IFriendsService _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 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())); - } - - // SendFriendRequestAsync - - [Test] - public void SendFriendRequestAsync_SendingToSelf_ThrowsInvalidOperationException() - { - // Arrange - var userId = Guid.NewGuid(); - - // Act & Assert - var ex = Assert.ThrowsAsync(() => - _service.SendFriendRequestAsync(userId, userId)); - - Assert.That(ex.Message, Is.EqualTo("Cannot send a request to self user")); - } - - [Test] - public void SendFriendRequestAsync_RequestAlreadyExists_ThrowsRequestAlreadySentException() - { - // Arrange - var fromUserId = Guid.NewGuid(); - var toUserId = Guid.NewGuid(); - - _friendshipsRepositoryMock - .Setup(repo => repo.Exist(fromUserId, toUserId)) - .Returns(true); - - // Act & Assert - Assert.ThrowsAsync(() => - _service.SendFriendRequestAsync(fromUserId, toUserId)); - } - - [Test] - public async Task SendFriendRequestAsync_ValidRequest_CallsAddAsync() - { - // Arrange - var fromUserId = Guid.NewGuid(); - var toUserId = Guid.NewGuid(); - - _friendshipsRepositoryMock - .Setup(repo => repo.Exist(fromUserId, toUserId)) - .Returns(false); - - // Act - await _service.SendFriendRequestAsync(fromUserId, toUserId); - - // Assert - _friendshipsRepositoryMock.Verify(repo => - repo.AddAsync(It.Is(f => - f.RequesterId == fromUserId && - f.AddresseeId == toUserId && - f.Status == FriendshipStatus.Pending && - f.Id != Guid.Empty - )), - Times.Once); - } - - // AcceptFriendRequestAsync - [Test] - public async Task AcceptFriendRequestAsync_ValidRequest_CallsUpdateAsync() - { - var friendship = _fixture.Build() - .With(f => f.Status, FriendshipStatus.Pending) - .Create(); - - _friendshipsRepositoryMock - .Setup(r => r.GetByIdAsync(friendship.Id)) - .ReturnsAsync(friendship); - - // Act: вызываем именно Accept, не Send - await _service.AcceptFriendRequestAsync(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.Accepted - )), Times.Once); - } - - [Test] - public void AcceptFriendRequestAsync_Throws_InvalidOperationException_IfStatusNotEqualPending() - { - var friendship = _fixture.Build() - .With(f => f.Status, FriendshipStatus.Accepted) - .Create(); - - _friendshipsRepositoryMock - .Setup(r => r.GetByIdAsync(friendship.Id)) - .ReturnsAsync(friendship); - - // Act & Assert - Assert.ThrowsAsync(async () => await _service.AcceptFriendRequestAsync(friendship.Id, friendship.AddresseeId)); - } - [Test] - public void AcceptFriendRequestAsync_Throws_UnauthorizedAccessException_IfCurrentUserIdIsNotAddressee() - { - // 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.AcceptFriendRequestAsync(friendship.Id, wrongUserId)); - } - - [Test] - public void AcceptFriendRequestAsync_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.AcceptFriendRequestAsync(requestId, userId)); - } - - [Test] - public async Task AcceptFriendRequestAsync_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.AcceptFriendRequestAsync(friendship.Id, friendship.AddresseeId); - - Assert.That(updatedFriendship.Status, Is.EqualTo(FriendshipStatus.Accepted)); - } - - // 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())); - } - - // 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; - }); - - _usersRepositoryMock.Setup(f => f.FindByIdAsync(userId)) - .ReturnsAsync(user); - - // 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(); - - _usersRepositoryMock - .Setup(r => r.FindByIdAsync(userId)) - .ThrowsAsync(new NotFoundByKeyException(userId)); - - // Act & Assert - Assert.ThrowsAsync(async () => - await _service.GetIncomingRequestsAsync(userId)); - } - -} diff --git a/Govor.API/Common/Extensions/AddOptionExtensions.cs b/Govor.API/Common/Extensions/AddOptionExtensions.cs new file mode 100644 index 0000000..060de2b --- /dev/null +++ b/Govor.API/Common/Extensions/AddOptionExtensions.cs @@ -0,0 +1,14 @@ +using Govor.Application.Services.Authentication; + +namespace Govor.API.Common.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/Common/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs new file mode 100644 index 0000000..301cf24 --- /dev/null +++ b/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs @@ -0,0 +1,158 @@ +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; +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.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; +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; +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; +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.Core.Repositories.UserSessionsRepository; +using Govor.Data; +using Govor.Data.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace Govor.API.Common.Extensions; + +public static class ConfigurationProgramExtensions +{ + public static void AddServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // Friends services + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(sp => + { + var env = sp.GetRequiredService(); + return new LocalStorageService(env.ContentRootPath); + }); + + services.AddHttpContextAccessor(); // it's very important for CurrentUserService + services.AddScoped(); + services.AddScoped(); + + services.AddMemoryCache(); + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // UserSession + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + + // Auto Mapper + services.AddAutoMapper(typeof(MappingProfile)); + + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + } + + public static void AddRepositories(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + // other + } + + public static void AddValidators(this IServiceCollection services) + { + services.AddScoped, UserValidator>(); + services.AddScoped, MessageValidator>(); + services.AddScoped, MediaAttachmentsValidator>(); + services.AddScoped, AdminValidator>(); + services.AddScoped, InvitationValidator>(); + services.AddScoped, FriendshipValidator>(); + services.AddScoped, PrivateChatValidator>(); + services.AddScoped, ChatGroupValidator>(); + } + + public static void AddGovorDbContext(this IServiceCollection services, IConfiguration configuration) + { + var useMySql = configuration.GetValue("UseMySql"); + + if (useMySql) + { + services.AddDbContext(options => + { + 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.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); + }); + } + } + + +} \ No newline at end of file diff --git a/Govor.API/Common/Extensions/ConfigurationSignalR.cs b/Govor.API/Common/Extensions/ConfigurationSignalR.cs new file mode 100644 index 0000000..935eb6c --- /dev/null +++ b/Govor.API/Common/Extensions/ConfigurationSignalR.cs @@ -0,0 +1,15 @@ +using Govor.API.Filters; +using Microsoft.AspNetCore.SignalR; + +namespace Govor.API.Common.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/Common/Extensions/ConfiguratorLoggerExtensions.cs b/Govor.API/Common/Extensions/ConfiguratorLoggerExtensions.cs new file mode 100644 index 0000000..a8ccddc --- /dev/null +++ b/Govor.API/Common/Extensions/ConfiguratorLoggerExtensions.cs @@ -0,0 +1,17 @@ +using Serilog; + +namespace Govor.API.Common.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/Common/Mapping/MappingProfile.cs b/Govor.API/Common/Mapping/MappingProfile.cs new file mode 100644 index 0000000..dcf9435 --- /dev/null +++ b/Govor.API/Common/Mapping/MappingProfile.cs @@ -0,0 +1,30 @@ +using AutoMapper; +using Govor.API.Extensions.Mapping; +using Govor.Application.Profiles; +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.Common.Mapping; + +public class MappingProfile : Profile +{ + public MappingProfile() + { + CreateMap(); + CreateMap(); + CreateMap(); + CreateMap(); + + CreateMap() + .AfterMap(); + + CreateMap(); + + CreateMap(); + + 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/Controllers/AdminStuff/FriendshipsController.cs b/Govor.API/Controllers/AdminStuff/FriendshipsController.cs new file mode 100644 index 0000000..94b5225 --- /dev/null +++ b/Govor.API/Controllers/AdminStuff/FriendshipsController.cs @@ -0,0 +1,92 @@ +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; +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(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(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." }); + } + } +} \ No newline at end of file diff --git a/Govor.API/Controllers/AdminStuff/InviteUserController.cs b/Govor.API/Controllers/AdminStuff/InviteUserController.cs index 0e2724b..204ae79 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; @@ -7,9 +7,9 @@ using Microsoft.AspNetCore.Mvc; namespace Govor.API.Controllers.AdminStuff; -[Route("api/[controller]")] +[Route("api/admin/[controller]")] [ApiController] -[Authorize(Roles = "Admin")] +//[Authorize(Roles = "Admin")] public class InviteUserController : Controller { private readonly IInvitesRepository _repository; @@ -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/Controllers/AdminStuff/UsersController.cs b/Govor.API/Controllers/AdminStuff/UsersController.cs index 4a4ae04..dd180e8 100644 --- a/Govor.API/Controllers/AdminStuff/UsersController.cs +++ b/Govor.API/Controllers/AdminStuff/UsersController.cs @@ -1,4 +1,7 @@ -using Govor.API.Services.AdminsStuff.Interfaces; +using Govor.Application.Interfaces; +using Govor.Contracts.Responses.Admins; +using Govor.Core.Models.Users; +using Govor.Data.Repositories.Exceptions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -22,15 +25,54 @@ 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(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 (NotFoundByKeyException ex) + { + _logger.LogWarning(ex, ex.Message); + return NotFound(ex.Message); + } + catch (Exception e) + { + _logger.LogError(e, e.Message); + return StatusCode(500, e.Message); + } } + + + 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/Authentication/AuthController.cs similarity index 61% rename from Govor.API/Controllers/AuthController.cs rename to Govor.API/Controllers/Authentication/AuthController.cs index 34badb2..897803f 100644 --- a/Govor.API/Controllers/AuthController.cs +++ b/Govor.API/Controllers/Authentication/AuthController.cs @@ -1,44 +1,56 @@ -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; -namespace Govor.API.Controllers; +namespace Govor.API.Controllers.Authentication; [ApiController] +[AllowAnonymous] [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 = _invitesService.Validate(registrationRequest.InviteLink); + 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}"); - return Ok(new { token }); + + _logger.LogInformation($"Register request for {user.Username} with id {user.Id} processed successfully"); + + var tokens = await _userSession.OpenSessionAsync(user, registrationRequest.DeviceInfo); + + _logger.LogInformation($"Session for user {user.Username} with id {user.Id} has been opened"); + + return Ok(tokens); } catch (UserAlreadyExistException ex) { @@ -62,34 +74,37 @@ 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}"); - return Ok(new { token }); + var user = await _accountService.LoginAsync(loginRequest.Name, loginRequest.Password); + _logger.LogInformation($"Login request for {user.Username} with id {user.Id} processed successfully"); + + var tokens = await _userSession.OpenSessionAsync(user, loginRequest.DeviceInfo); + + _logger.LogInformation($"Session for user {user.Username} with id {user.Id} has been opened"); + + return Ok(tokens); } 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."); } } diff --git a/Govor.API/Controllers/Authentication/RefreshController.cs b/Govor.API/Controllers/Authentication/RefreshController.cs new file mode 100644 index 0000000..ebf95c1 --- /dev/null +++ b/Govor.API/Controllers/Authentication/RefreshController.cs @@ -0,0 +1,61 @@ +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; + +[ApiController] +[AllowAnonymous] +[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] + [HttpPost("refresh")] // api/auth/token/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/Authentication/SessionKeysController.cs b/Govor.API/Controllers/Authentication/SessionKeysController.cs new file mode 100644 index 0000000..77929ec --- /dev/null +++ b/Govor.API/Controllers/Authentication/SessionKeysController.cs @@ -0,0 +1,110 @@ +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; + +namespace Govor.API.Controllers.Authentication; + +[RequireHttps] +[ApiController] +[Route("api/session")] +[Authorize(Roles = "Admin, User")] +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 _sessionKeyAttacher.AttachKeysAsync(sessionId, + request.IdentityKey, + request.SignedPreKey, + request.SignedPreKeySignature, + request.OneTimePreKeys); + + return Ok(); + } + + [HttpGet("users/{userId}/keys")] + public async Task GetUserPublicKeys(Guid userId) + { + var requesterId = _currentUser.GetCurrentUserId(); + + if (!(await _friendshipService.GetFriendsAsync(userId)).Select(u => u.Id).Contains(requesterId)) + return Forbid("You can only access keys of your friends"); + + 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/Controllers/ChatLoadController.cs b/Govor.API/Controllers/ChatLoadController.cs new file mode 100644 index 0000000..fcda9ca --- /dev/null +++ b/Govor.API/Controllers/ChatLoadController.cs @@ -0,0 +1,125 @@ +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; + +namespace Govor.API.Controllers; + +[ApiController] +[Authorize(Roles = "Admin, User")] +[Route("api/chats")] +public class ChatLoadController : Controller +{ + 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 GetGroupMessages( + Guid chatId, + [FromQuery] MessageQuery query) + { + try + { + 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(), + query.StartMessageId, + query.Before, + query.After); + + var response = _mapper.Map>(result); + + return Ok(response); + } + catch (UnauthorizedAccessException ex) + { + _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); + return StatusCode(500, "Unexpected Error! Please try again later."); + } + } + + [HttpGet("user-messages")] + public async Task GetUserMessages( + Guid userId, + [FromQuery] MessageQuery query) + { + try + { + 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(), + query.StartMessageId, + query.Before, + query.After); + + 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 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); + 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 new file mode 100644 index 0000000..542ee89 --- /dev/null +++ b/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs @@ -0,0 +1,91 @@ +using AutoMapper; +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; + +namespace Govor.API.Controllers.Friends; + + +[Authorize] +[Route("api/friends")] +[ApiController] +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, + IMapper mapper) + { + _logger = logger; + _mapper = mapper; + _friendsService = friendsService; + _currentUserService = currentUserService; + } + + [HttpGet("requests")] // api/friends/requests + public async Task GetIncomingRequests() + { + try + { + var result = await _friendsService.GetIncomingAsync(_currentUserService.GetCurrentUserId()); + var response = _mapper.Map>(result); + + return Ok(response); + } + catch (InvalidOperationException ex) + { + _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); + return StatusCode(500, new { error = "Internal server error." }); + } + } + + [HttpGet("responses")]// api/friends/responses + public async Task GetResponses() + { + try + { + 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); + } + catch (InvalidOperationException ex) + { + _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); + return StatusCode(500, new { error = "Internal server error." }); + } + } +} \ 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..25c0065 --- /dev/null +++ b/Govor.API/Controllers/Friends/FriendshipController.cs @@ -0,0 +1,84 @@ +using AutoMapper; +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; + +namespace Govor.API.Controllers.Friends; + +[Route("api/friends")] +public class FriendshipController : Controller +{ + private readonly ILogger _logger; + private readonly IFriendshipService _friendsService; + private readonly ICurrentUserService _currentUserService; + private readonly IMapper _mapper; + + public FriendshipController( + ILogger logger, + IFriendshipService friendsService, + ICurrentUserService currentUserService, + IMapper mapper) + { + _logger = logger; + _mapper = mapper; + _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()); + + var response = _mapper.Map>(result); + + return Ok(response); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, ex.Message); + return Forbid(ex.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + return StatusCode(500, new { error = "Internal error during user search." }); + } + } + + [HttpGet] // api/friends + public async Task GetFriends() + { + try + { + var result = await _friendsService.GetFriendsAsync(_currentUserService.GetCurrentUserId()); + + var response = _mapper.Map>(result); + + return Ok(response); + } + catch (InvalidOperationException ex) + { + _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); + return StatusCode(500, new { error = "Internal server error." }); + } + } +} \ No newline at end of file diff --git a/Govor.API/Controllers/FriendsController.cs b/Govor.API/Controllers/FriendsController.cs deleted file mode 100644 index 7000c2a..0000000 --- a/Govor.API/Controllers/FriendsController.cs +++ /dev/null @@ -1,164 +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")] - 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(targetUserId, _currentUserService.GetCurrentUserId()); - 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(new { error = "Failed to get friend requests. User data missing." }); - } - catch (Exception ex) - { - _logger.LogError(ex, ex.Message); - return StatusCode(500, new { error = "Internal server error." }); - } - } - - [HttpPost("accept")] - public async Task AcceptFriend(Guid requesterId) - { - if (requesterId == Guid.Empty) - return BadRequest("Requester ID is invalid"); - - try - { - await _friendsService.AcceptFriendRequestAsync(requesterId, _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." }); - } - } - - [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 BadRequest(new { error = "User data not found." }); - } - 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/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/Controllers/MediaController.cs b/Govor.API/Controllers/MediaController.cs new file mode 100644 index 0000000..6c0a870 --- /dev/null +++ b/Govor.API/Controllers/MediaController.cs @@ -0,0 +1,126 @@ +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; + +namespace Govor.API.Controllers; + +[ApiController] +[Route("api/media")] +[Authorize(Roles = "User,Admin")] +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; + } + + [HttpPost("upload")] + [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 + { + byte[] fileBytes = await ReadFileAsync(request.FromFile); + + var media = new Media( + _currentUserService.GetCurrentUserId(), + DateTime.UtcNow, + Path.GetFileName(request.FromFile.FileName), + fileBytes, + request.Type, + request.MimeType, + request.EncryptedKey, + request.OwnerType, + null) + { + OwnerType = request.OwnerType, + OwnerId = request.OwnerType == MediaOwnerType.Avatar + ? _currentUserService.GetCurrentUserId() + : null, + }; + + var result = await _mediaService.UploadMediaAsync(media); + + _logger.LogInformation("Uploaded file {FileName} from user {UserId}", + media.FileName, media.UploaderId); + + return Ok(result); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, ex.Message); + return Forbid(ex.Message); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, ex.Message); + return BadRequest(ex.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error uploading media"); + return StatusCode(500, "Internal server error"); + } + } + + 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 + { + var userId = _currentUserService.GetCurrentUserId(); + + if (!await _accesser.HasAccessAsync(id, userId)) + return Forbid(); + + var media = await _mediaService.GetMediaByIdAsync(id); + + return File(media.Data, media.MimeType, Path.GetFileName(media.FileName)); + } + catch (KeyNotFoundException ex) + { + _logger.LogWarning(ex, ex.Message); + return NotFound("Media not found"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error downloading media"); + return StatusCode(500, "Internal server error"); + } + } +} diff --git a/Govor.API/Controllers/OnlinePingingController.cs b/Govor.API/Controllers/OnlinePingingController.cs new file mode 100644 index 0000000..0a58dfc --- /dev/null +++ b/Govor.API/Controllers/OnlinePingingController.cs @@ -0,0 +1,79 @@ +using Govor.Application.Interfaces; +using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.Application.Interfaces.UserOnlineStatus; +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 IUserPresenceReader _presenceReader; + private readonly IOnlineUserStore _userOnlineStore; + 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, "Failed to ping."); + } + } + + [HttpGet("status/{userId}")] + public async Task GetStatus(Guid userId) + { + try + { + var isOnline = _userOnlineStore.IsOnline(userId); + var lastSeen = await _presenceReader.GetLastSeenAsync(userId); + + return Ok(new { + isOnline, + lastSeen + }); + } + catch (Exception e) + { + _logger.LogError(e, e.Message); + return StatusCode(500, "Internal server error."); + } + } +} \ No newline at end of file diff --git a/Govor.API/Controllers/ProfileController.cs b/Govor.API/Controllers/ProfileController.cs new file mode 100644 index 0000000..f5091ee --- /dev/null +++ b/Govor.API/Controllers/ProfileController.cs @@ -0,0 +1,152 @@ +using AutoMapper; +using Govor.API.Hubs; +using Govor.Application.Interfaces; +using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.Application.Interfaces.Medias; +using Govor.Contracts.DTOs; +using Govor.Contracts.Requests; +using Govor.Core.Models; +using Govor.Data.Repositories.Exceptions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; + +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 IHubContext _profileHubContext; + private readonly IMapper _mapper; + + public ProfileController( + ILogger logger, + IMediaService mediaService, + IProfileService profileService, + ICurrentUserService currentUserService, + IHubContext profileHubContext, + IMapper mapper) + { + _logger = logger; + _mediaService = mediaService; + _profileService = profileService; + _profileHubContext = profileHubContext; + _currentUserService = currentUserService; + _mapper = mapper; + } + + [HttpPost("avatar")] // api/profile/avatar + public async Task UploadAvatar([FromForm] AvatarUploadRequest request) + { + + var userId = _currentUserService.GetCurrentUserId(); + + if (request.FromFile == null || request.FromFile.Length == 0) + { + return BadRequest("File is empty."); + } + + try + { + byte[] fileBytes = await ReadFileAsync(request.FromFile); + + var media = new Media( + _currentUserService.GetCurrentUserId(), + DateTime.UtcNow, + Path.GetFileName(request.FromFile.FileName), + fileBytes, + request.Type, + request.MimeType, + String.Empty, + MediaOwnerType.Avatar, + userId); + + var mediaInfo = await _mediaService.UploadMediaAsync(media); + + await _profileService.SetNewIcon(userId, mediaInfo.MediaId); + var iconId = mediaInfo.MediaId; + + var payload = new { userId, iconId }; + + await _profileHubContext.Clients.All.SendAsync( + "AvatarUpdated", + payload + ); + + + return Ok(mediaInfo); + } + catch (System.Exception ex) + { + return StatusCode(500, new { Message = "Avatar processing error.", Details = ex.Message }); + } + } + + private async Task ReadFileAsync(IFormFile file) + { + await using var ms = new MemoryStream(); + await file.CopyToAsync(ms); + return ms.ToArray(); + } + + [HttpGet("download/me")] + public async Task DownloadProfile() + { + try + { + var userId = _currentUserService.GetCurrentUserId(); + var user = await _profileService.GetUserProfileAsync(userId); + + 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 NotFound("Profile not found"); + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + return StatusCode(500, "Unexpected Error! Please try again later."); + } + } + + [HttpGet("download/{id}")] + public async Task DowloadProfileByUserId(Guid id) + { + try + { + var user = await _profileService.GetUserProfileAsync(id); + + 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 NotFound("Profile not found"); + } + 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 new file mode 100644 index 0000000..489579f --- /dev/null +++ b/Govor.API/Controllers/SessionController.cs @@ -0,0 +1,145 @@ +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 ICurrentUserSessionService _currentUserSessionService; + private readonly IUserSessionReader _userSessionReader; + private readonly IUserSessionRevoker _userSessionRevoker; + private readonly IMapper _mapper; + + 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; + } + + [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")] + 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() + { + 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.API/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Extensions/ConfigurationProgramExtensions.cs deleted file mode 100644 index 76876ef..0000000 --- a/Govor.API/Extensions/ConfigurationProgramExtensions.cs +++ /dev/null @@ -1,77 +0,0 @@ -using Govor.API.Services.AdminsStuff.Interfaces; -using Govor.API.Services.Authentication.Interfaces; -using Govor.Application.Infrastructure.Extensions; -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; -using Govor.Core.Repositories.Admins; -using Govor.Core.Repositories.Friendships; -using Govor.Core.Repositories.Invaites; -using Govor.Core.Repositories.MediasAttachments; -using Govor.Core.Repositories.Messages; -using Govor.Core.Repositories.Users; -using Govor.Data; -using Govor.Data.Repositories; -using Microsoft.EntityFrameworkCore; - -namespace Govor.API.Extensions; - -public static class ConfigurationProgramExtensions -{ - public static void AddServices(this IServiceCollection services) - { - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - - services.AddScoped(sp => - { - var env = sp.GetRequiredService(); - return new LocalStorageService(env.ContentRootPath); - }); - - services.AddHttpContextAccessor(); // it's very important for CurrentUserService - services.AddScoped(); - } - - public static void AddRepositories(this IServiceCollection services) - { - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - } - - public static void AddValidators(this IServiceCollection services) - { - services.AddScoped, UserValidator>(); - services.AddScoped, MessageValidator>(); - services.AddScoped, MediaAttachmentsValidator>(); - services.AddScoped, AdminValidator>(); - services.AddScoped, InvitationValidator>(); - services.AddScoped, FriendshipValidator>(); - } - - public static void AddGovorDbContext(this IServiceCollection services, IConfiguration configuration) - { - services.AddDbContext( - options => - { - options.UseNpgsql(configuration.GetConnectionString(nameof(GovorDbContext))); - } - ); - } -} \ 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 d855102..0f12b70 100644 --- a/Govor.API/Govor.API.csproj +++ b/Govor.API/Govor.API.csproj @@ -1,29 +1,33 @@ - net9.0 - enable + net8.0 + disable enable - + + - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - + + + + - + + + - - + + + + + + + diff --git a/Govor.API/Hubs/ChatsHub.cs b/Govor.API/Hubs/ChatsHub.cs index b959c3f..9ffedfa 100644 --- a/Govor.API/Hubs/ChatsHub.cs +++ b/Govor.API/Hubs/ChatsHub.cs @@ -1,103 +1,316 @@ -using System.Security.Claims; -using Govor.Core.Models; -using Govor.Core.Repositories.Users; -using Govor.Data.Repositories.Exceptions; +using Govor.API.Common.SignalR.Helpers; +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.Messages; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; namespace Govor.API.Hubs; -[Authorize] +[Authorize] // api/chats public class ChatsHub : Hub { - private readonly IUsersRepository _usersRepository; private readonly ILogger _logger; + private readonly IMessageCommandService _messageCommandService; + private readonly IUserGroupsService _userService; + private readonly IHubUserAccessor _userAccessor; - public ChatsHub(IUsersRepository usersRepository, ILogger logger) + public ChatsHub( + ILogger logger, + IMessageCommandService messageCommandService, + IUserGroupsService userService, + IHubUserAccessor userAccessor) { - _usersRepository = usersRepository; _logger = logger; + _messageCommandService = messageCommandService; + _userService = userService; + _userAccessor = userAccessor; } public override async Task OnConnectedAsync() { - var userId = GetUserId(); - if (userId != Guid.Empty) + var userId = _userAccessor.GetUserId(Context); + if (userId == Guid.Empty) { - // Привязываем ConnectionId к 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(); + return; } + + 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}"); + } + await base.OnConnectedAsync(); } - public override async Task OnDisconnectedAsync(Exception? exception) + public override async Task OnDisconnectedAsync(Exception exception) { - var userId = GetUserId(); + var userId = _userAccessor.GetUserId(Context, true); if (userId != Guid.Empty) { 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); + + var userGroups = await _userService.GetUserGroupsAsync(userId); + + 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 Edit(string newMessage, Guid messageId) + public async Task> Send(MessageRequest request) { - - } - - public async Task Send(string message, Guid toUserId) - { - // Валидация входных данных - if (string.IsNullOrWhiteSpace(message)) - { - _logger.LogWarning("Empty message received from user {UserId}", GetUserId()); - throw new ArgumentException("Message cannot be empty", nameof(message)); - } - - var senderId = GetUserId(); - - // Проверка существования получателя - try - { - await _usersRepository.FindByIdAsync(toUserId); - } - catch (NotFoundByKeyException ex) - { - _logger.LogWarning("Recipient user {ToUserId} not found", toUserId); - throw; - } - catch (ArgumentException ex) - { - _logger.LogWarning("Invalid recipient userId received from user {UserId}", GetUserId()); - throw; - } - - try - { - _logger.LogInformation("Message sent from {SenderId} to {RecipientId}: {Message}", senderId, toUserId, message); + var senderId = _userAccessor.GetUserId(Context); - // Отправка сообщения отправителю и получателю - await Clients.Group(toUserId.ToString()).SendAsync("Receive", message, senderId); - await Clients.Group(senderId.ToString()).SendAsync("Receive", message, senderId); + if (string.IsNullOrWhiteSpace(request.EncryptedContent) && + (request.MediaAttachments == null || !request.MediaAttachments.Any())) + { + _logger.LogWarning("Empty message received from user {UserId}", senderId); + 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); + + 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 _messageCommandService.SendMessageAsync(sendMessageParams); + + if (!result.IsSuccess || result.Message.Id == Guid.Empty) + return LogAndError(senderId, request.RecipientId, "Failed to send message", result.Exception); + + var response = BuildUserMessageResponse(result.Message, request.ReplyToMessageId); + + await NotifyClientsAboutMessage(response); + + return HubResult.Ok(response); + } + catch (UnauthorizedAccessException ex) + { + return LogAndUnauthorized(ex, "Unauthorized sending attempt", senderId, + request.RecipientId); + } + catch (FriendshipException) + { + 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 sending message from {SenderId} to {RecipientId}", senderId, toUserId); - throw; + return LogAndError(senderId, request.RecipientId, "Unhandled exception", ex); + } + } + + public async Task> Remove(RemoveMessageRequest request) + { + var removerId = _userAccessor.GetUserId(Context); + _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 = _userAccessor.GetUserId(Context); + _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 || result.OriginalMessage == null) + return LogAndError(editor, request.MessageId, "Edit message error", + result.Exception); + + 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) + { + 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); + } + } + + + #region common + 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); + } + } + + 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); } } - private Guid GetUserId() + // Logging helpers + private HubResult LogAndError(Guid userId, Guid targetId, string message, Exception ex) { - 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"); - } - return userId; + _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."); + } + #endregion } \ No newline at end of file diff --git a/Govor.API/Hubs/FriendsHub.cs b/Govor.API/Hubs/FriendsHub.cs new file mode 100644 index 0000000..c9a379c --- /dev/null +++ b/Govor.API/Hubs/FriendsHub.cs @@ -0,0 +1,174 @@ +using AutoMapper; +using Govor.API.Common.SignalR.Helpers; +using Govor.Application.Exceptions.FriendsService; +using Govor.Application.Interfaces.Friends; +using Govor.Contracts.DTOs; +using Govor.Contracts.Responses.SignalR; +using Microsoft.AspNetCore.SignalR; + +namespace Govor.API.Hubs; + +public class FriendsHub : Hub +{ + private readonly ILogger _logger; + private readonly IFriendRequestCommandService _friendRequestService; + private readonly IHubUserAccessor _userAccessor; + private readonly IMapper _mapper; + + public FriendsHub( + ILogger logger, + IFriendRequestCommandService friendRequestService, + IHubUserAccessor userAccessor, + IMapper mapper) + { + _logger = logger; + _friendRequestService = friendRequestService; + _userAccessor = userAccessor; + _mapper = mapper; + } + + public override async Task OnConnectedAsync() + { + var userId = _userAccessor.GetUserId(Context); + if (userId == Guid.Empty) + { + _logger.LogWarning("User connected with invalid UserID claim."); + Context.Abort(); + return; + } + + 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 = _userAccessor.GetUserId(Context, true); + 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 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 + { + var userId = _userAccessor.GetUserId(Context); + 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(); + } + 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) + { + try + { + var userId = _userAccessor.GetUserId(Context); + var friendship = await _friendRequestService.AcceptAsync(friendshipId, userId); + + await Clients.Group(userId.ToString()) + .SendAsync("FriendRequestAccepted", _mapper.Map(friendship)); + + await Clients.Group(friendship.RequesterId.ToString()) + .SendAsync( "YourFriendRequestAccepted", _mapper.Map(friendship.Addressee)); + + _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) + { + try + { + var userId = _userAccessor.GetUserId(Context); + var friendship = await _friendRequestService.RejectAsync(friendshipId, userId); + var dto = _mapper.Map(friendship); + + await Clients.Group(userId.ToString()) + .SendAsync("FriendRequestRejected", dto); + + await Clients.Group(friendship.RequesterId.ToString()) + .SendAsync("YourFriendRequestRejected", dto); + + _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/Hubs/UsersHub.cs b/Govor.API/Hubs/GroupsHub.cs similarity index 70% rename from Govor.API/Hubs/UsersHub.cs rename to Govor.API/Hubs/GroupsHub.cs index 880aba6..2469e1e 100644 --- a/Govor.API/Hubs/UsersHub.cs +++ b/Govor.API/Hubs/GroupsHub.cs @@ -2,7 +2,7 @@ using Microsoft.AspNetCore.SignalR; namespace Govor.API.Hubs; -public class UsersHub : Hub +public class GroupsHub : Hub { } \ No newline at end of file diff --git a/Govor.API/Hubs/PresenceHub.cs b/Govor.API/Hubs/PresenceHub.cs new file mode 100644 index 0000000..c06de5c --- /dev/null +++ b/Govor.API/Hubs/PresenceHub.cs @@ -0,0 +1,94 @@ +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; + +namespace Govor.API.Hubs; + +[Authorize(Roles = "Admin, User")] +[Route("hubs/presence")] +public class PresenceHub : Hub +{ + private readonly ILogger _logger; + private readonly IUserNotificationScopeService _scopeService; + private readonly IOnlineUserStore _onlineUserStore; + private readonly IHubUserAccessor _userAccessor; + private readonly IUsersRepository _users; + + public PresenceHub( + ILogger logger, + IUserNotificationScopeService scopeService, + IOnlineUserStore onlineUserStore, + IHubUserAccessor userAccessor, + IUsersRepository users) + { + _logger = logger; + _users = users; + _scopeService = scopeService; + _onlineUserStore = onlineUserStore; + _userAccessor = userAccessor; + } + + public override async Task OnConnectedAsync() + { + var userId = _userAccessor.GetUserId(Context); + if (userId == Guid.Empty || await _users.ExistsByIdAsync(userId) == false) + { + _logger.LogWarning("User connected with invalid UserId claim."); + Context.Abort(); + return; + } + + _onlineUserStore.SetOnlineUser(userId); + await Groups.AddToGroupAsync(Context.ConnectionId, userId.ToString()); + + var friends = await _scopeService.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 = _userAccessor.GetUserId(Context, true); + + 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); + + // 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) + { + await Clients.Group(recipient.ToString()) + .SendAsync("UserOffline", userId); + } + + await base.OnDisconnectedAsync(exception); + } +} \ 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..cd74f69 --- /dev/null +++ b/Govor.API/Hubs/ProfileHub.cs @@ -0,0 +1,203 @@ +using Govor.API.Common.SignalR.Helpers; +using Govor.Application.Interfaces; +using Govor.Application.Interfaces.Friends; +using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.Application.Interfaces.Medias; +using Govor.Contracts.Responses.SignalR; +using Govor.Core.Models; +using Govor.Core.Models.Users; +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 IFriendshipService _friendsService; + private readonly IProfileService _profileService; + private readonly IHubUserAccessor _userAccessor; + private readonly ILogger _logger; + private readonly IMediaService _mediaService; + + public ProfileHub( + IGroupsRepository groupsRepository, + IFriendshipService 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 + try + { + var friendships = await _friendsService.GetFriendsAsync(userId); + foreach (var friends in friendships) + await Groups.AddToGroupAsync(Context.ConnectionId, $"friends-{friends.Id}"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get friends for user {userId}", userId); + } + + // Groups + try + { + var groups = await _groupsRepository.GetByUserIdAsync(userId); + foreach (var group in groups) + await Groups.AddToGroupAsync(Context.ConnectionId, $"group-{group.Id}"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get groups for user {userId}", userId); + } + + _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 + try + { + var friendships = await _friendsService.GetFriendsAsync(userId); + + foreach (var friendship in friendships) + await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"friends-{friendship.Id}"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to remove user {userId} from friend groups", userId); + } + + // Groups + try + { + var groups = await _groupsRepository.GetByUserIdAsync(userId); + + foreach (var group in groups) + await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"group-{group.Id}"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to remove user {userId} from groups", userId); + } + + await base.OnDisconnectedAsync(exception); + } + + public async Task> SetDescription(string description) + { + if(description.Length > 500) + return HubResult.Error("The description cannot be longer than 500 characters."); + + 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 = Enumerable.Empty(); + var groups = Enumerable.Empty(); + + try + { + friendIds = await _friendsService.GetFriendsAsync(userId); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load friend list for notifications. userId: {userId}", userId); + } + + try + { + groups = await _groupsRepository.GetByUserIdAsync(userId); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load group list for notifications. userId: {userId}", userId); + } + + var groupIds = groups.Select(g => g.Id).ToList(); + + // Current user + await Clients.Group($"user-{userId}") + .SendAsync(eventName, payload); + + // Friends + foreach (var friend in friendIds) + { + await Clients.Group($"friends-{friend.Id}") + .SendAsync(eventName, payload); + } + + // Groups + 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 bf1019a..6489eb3 100644 --- a/Govor.API/Program.cs +++ b/Govor.API/Program.cs @@ -1,7 +1,7 @@ -using System.Text; -using Govor.API.Extensions; +using System.Text; +using Govor.API.Common.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; @@ -11,23 +11,30 @@ 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:3000", "https://localhost:3000") - .AllowAnyHeader() - .AllowAnyMethod() - .AllowCredentials(); + policy.WithOrigins( + "https://localhost:7155", + "http://localhost:7155", + "https://govor-team-govor-8ce1.twc1.net", + "http://govor-team-govor-8ce1.twc1.net" + ) + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials(); }); }); -builder.Services.Configure(configuration.GetSection(nameof(JwtOption))); +builder.Services.Configure(configuration.GetSection(nameof(JwtAccessOption))); // Add services -builder.Services.AddSignalR(); +builder.Services.AddSignalRConf();// signalR builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => @@ -39,7 +46,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 { @@ -65,6 +72,8 @@ builder.Services.AddServices(); builder.Services.AddRepositories(); builder.Services.AddValidators(); +builder.Services.AddOptionsConfiguration(configuration); + builder.Services.AddGovorDbContext(configuration); // GovorDbContext init builder.Services.AddEndpointsApiExplorer(); @@ -94,18 +103,23 @@ builder.Services.AddSwaggerGen(options => }); }); + //builder.Services.AddOpenApi(); var app = builder.Build(); // Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) +if (!app.Environment.IsDevelopment()) { //app.MapOpenApi(); - app.UseSwagger(); - app.UseSwaggerUI(); + builder.WebHost.UseUrls("http://0.0.0.0:8080"); } +app.UseSwagger(); +app.UseSwaggerUI(); + +app.UseCors("AllowFrontend"); + app.UseHttpsRedirection(); app.UseRouting(); @@ -114,9 +128,13 @@ app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); -app.MapHub("/api/chats"); -app.MapSwagger().RequireAuthorization(); +app.MapHub("/hubs/chats"); +app.MapHub("/hubs/friends"); +app.MapHub("/hubs/profiles"); + +app.MapSwagger() + .RequireAuthorization(); app.Map("/", () => "Not for browsers"); diff --git a/Govor.API/Properties/launchSettings.json b/Govor.API/Properties/launchSettings.json index 91aa164..defffb2 100644 --- a/Govor.API/Properties/launchSettings.json +++ b/Govor.API/Properties/launchSettings.json @@ -5,7 +5,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "http://localhost:5041", + "applicationUrl": "http://0.0.0.0:8080;", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -13,8 +13,8 @@ "https": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "https://localhost:7155;http://localhost:5041", + "launchBrowser": true, + "applicationUrl": "https://0.0.0.0:8080;", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/Govor.API/appsettings.json b/Govor.API/appsettings.json index be2ac70..d3b602d 100644 --- a/Govor.API/appsettings.json +++ b/Govor.API/appsettings.json @@ -6,11 +6,18 @@ } }, "ConnectionStrings": { - "GovorDbContext": "Host=localhost;Port=5432;Database=DbGovor;Username=postgres;Password=stalcker;" + "GovorDbContext": "Host=localhost;Port=5432;Database=GovorDb;Username=postgres;Password=stalcker;" }, + "UseMySql": false, "AllowedHosts": "*", - "JwtOption": { - "SecretKeу": "MY VERY SECRET KEY asdasdpafjhasofafpajsfj", - "Hours": "24" + "JwtAccessOption": { + "SecretKey": "Q89eY7zP7C4+TqLmHF4kw9xkF1E8Ru4Zpg+up9wFt9g=", + "Minutes": 10 + }, + "JwtRefreshOption": { + "RefreshTokenLifetimeDays": 30 + }, + "EncryptionOption": { + "Secret": "4r8B2j9kP5mX7nQwE2zY3A==" } } diff --git a/Govor.Application.Tests/Govor.Application.Tests.csproj b/Govor.Application.Tests/Govor.Application.Tests.csproj new file mode 100644 index 0000000..19dfd3d --- /dev/null +++ b/Govor.Application.Tests/Govor.Application.Tests.csproj @@ -0,0 +1,35 @@ + + + + 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 95% rename from Govor.API.Tests/UnitTests/Services/AdminStuff/InvitationGeneratorTests.cs rename to Govor.Application.Tests/Infrastructure/AdminsStuff/InvitationGeneratorTests.cs index 98c50a1..698b6ed 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.Application.Interfaces; 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.Application.Tests/Infrastructure/Extensions/CurrentUserServiceTests.cs b/Govor.Application.Tests/Infrastructure/Extensions/CurrentUserServiceTests.cs new file mode 100644 index 0000000..31fc66b --- /dev/null +++ b/Govor.Application.Tests/Infrastructure/Extensions/CurrentUserServiceTests.cs @@ -0,0 +1,107 @@ +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] +public class CurrentUserServiceTests +{ + private Mock _httpContextAccessorMock; + private ICurrentUserService _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.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.API.Tests/UnitTests/Services/Validators/UsernameValidatorTests.cs b/Govor.Application.Tests/Infrastructure/Validators/UsernameValidatorTests.cs similarity index 91% rename from Govor.API.Tests/UnitTests/Services/Validators/UsernameValidatorTests.cs rename to Govor.Application.Tests/Infrastructure/Validators/UsernameValidatorTests.cs index 510baba..60daae4 100644 --- a/Govor.API.Tests/UnitTests/Services/Validators/UsernameValidatorTests.cs +++ b/Govor.Application.Tests/Infrastructure/Validators/UsernameValidatorTests.cs @@ -1,7 +1,7 @@ -using Govor.Application.Validators; // или другой namespace 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 @@ -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.Tests/UnitTests/Services/Authentication/AuthServiceTests.cs b/Govor.Application.Tests/Services/Authentication/AuthServiceTests.cs similarity index 95% rename from Govor.API.Tests/UnitTests/Services/Authentication/AuthServiceTests.cs rename to Govor.Application.Tests/Services/Authentication/AuthServiceTests.cs index 3abea94..ee3964a 100644 --- a/Govor.API.Tests/UnitTests/Services/Authentication/AuthServiceTests.cs +++ b/Govor.Application.Tests/Services/Authentication/AuthServiceTests.cs @@ -2,14 +2,14 @@ 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; using Moq; -namespace Govor.API.Tests.UnitTests.Services.Authentication; +namespace Govor.Application.Tests.Services.Authentication; [TestFixture] public class AuthServiceTests @@ -43,13 +43,13 @@ public class AuthServiceTests _accountService = new AuthService( _usersRepositoryMock.Object, - _jwtServiceMock.Object, _passwordHasherMock.Object, _adminsRepositoryMock.Object, _usernameValidatorMock.Object ); } + // 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 new file mode 100644 index 0000000..777e876 --- /dev/null +++ b/Govor.Application.Tests/Services/Authentication/JwtServiceTests.cs @@ -0,0 +1,157 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using AutoFixture; +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; + +[TestFixture] +public class JwtServiceTests +{ + private Fixture _fixture; + private Mock> _jwtOptionsMock; + private Mock> _jwtRefreshOptionsMock; + private Mock _invitesServiceMock; + private IJwtService _jwtService; + + private JwtAccessOption _testJwtAccessOptions; + private JwtRefreshOption _testJwtRefreshOptions; + + [SetUp] + public void SetUp() + { + _fixture = new Fixture(); + _fixture.Behaviors.OfType().ToList().ForEach(b => _fixture.Behaviors.Remove(b)); + _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); + + _testJwtAccessOptions = new JwtAccessOption + { + SecretKey = "THIS_IS_A_TEST_SECRET_KEY_THAT_IS_LONG_ENOUGH_1234", // Ensure key size is sufficient for HMACSHA256 + Minutes = 5 + }; + + _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, + _jwtRefreshOptionsMock.Object, + _invitesServiceMock.Object); + } + + [Test] + public async Task GenerateJwtToken_ShouldReturnValidJwtString_WithCorrectClaims() + { + // Arrange + var user = _fixture.Create(); + var sessionId = Guid.NewGuid(); + var expectedRole = "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 + 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() + { + // 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.SecretKey)); + + 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/Authentication/JwtTokenHasherTests.cs b/Govor.Application.Tests/Services/Authentication/JwtTokenHasherTests.cs new file mode 100644 index 0000000..9e62e54 --- /dev/null +++ b/Govor.Application.Tests/Services/Authentication/JwtTokenHasherTests.cs @@ -0,0 +1,51 @@ +using AutoFixture; +using Govor.Application.Interfaces.Authentication; +using Govor.Application.Services.Authentication; + +namespace Govor.Application.Tests.Services.Authentication; + +[TestFixture] +public class JwtTokenHasherTests +{ + private IJwtTokenHasher _jwtTokenHasher; + private Fixture _fixture; + + [SetUp] + public void StarUp() + { + _fixture = new Fixture(); + _jwtTokenHasher = new JwtTokenHasher(); + } + + [Test] + public void Given_Token_When_Hash_Then_Hash_And_Verify_Then_Result_Should_Be_True() + { + // Arrange + string token = _fixture.Create(); + + // Act + string hash = _jwtTokenHasher.HashToken(token); + + var result = _jwtTokenHasher.VerifyToken(token, hash); + + // Assert + Assert.That(hash, Is.Not.EqualTo(token)); + Assert.That(result, Is.True); + } + + [Test] + public void Given_Token_NotToken_When_Hash_Should_Not_Be_True_Then_Result_Should_Be_False() + { + // Arrange + string token = _fixture.Create(); + string notToken = _fixture.Create(); + + // Act + string hash = _jwtTokenHasher.HashToken(token); + + var result = _jwtTokenHasher.VerifyToken(notToken, hash); + + // Assert + Assert.That(result, Is.False); + } +} \ No newline at end of file diff --git a/Govor.Application.Tests/Services/Friends/FriendRequestCommandServiceTests.cs b/Govor.Application.Tests/Services/Friends/FriendRequestCommandServiceTests.cs new file mode 100644 index 0000000..2cfd781 --- /dev/null +++ b/Govor.Application.Tests/Services/Friends/FriendRequestCommandServiceTests.cs @@ -0,0 +1,285 @@ +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.Application.Tests.Services.Friends; + +[TestFixture] +public class FriendRequestCommandServiceTests +{ + private Fixture _fixture; + private Mock _usersRepositoryMock; + private Mock _friendshipsRepositoryMock; + private IFriendRequestCommandService _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 FriendRequestCommandService(_friendshipsRepositoryMock.Object); + } + + // SendFriendRequestAsync + [Test] + public void SendFriendRequestAsync_SendingToSelf_ThrowsInvalidOperationException() + { + // Arrange + var userId = Guid.NewGuid(); + + // Act & Assert + var ex = Assert.ThrowsAsync(() => + _service.SendAsync(userId, userId)); + + Assert.That(ex.Message, Is.EqualTo("Cannot send a request to self user")); + } + + [Test] + public void SendFriendRequestAsync_RequestAlreadyExists_ThrowsRequestAlreadySentException() + { + // Arrange + var fromUserId = Guid.NewGuid(); + var toUserId = Guid.NewGuid(); + + _friendshipsRepositoryMock + .Setup(repo => repo.Exist(fromUserId, toUserId)) + .Returns(true); + + // Act & Assert + Assert.ThrowsAsync(() => + _service.SendAsync(fromUserId, toUserId)); + } + + [Test] + public async Task SendFriendRequestAsync_ValidRequest_CallsAddAsync() + { + // Arrange + var fromUserId = Guid.NewGuid(); + var toUserId = Guid.NewGuid(); + + _friendshipsRepositoryMock + .Setup(repo => repo.Exist(fromUserId, toUserId)) + .Returns(false); + + // Act + await _service.SendAsync(fromUserId, toUserId); + + // Assert + _friendshipsRepositoryMock.Verify(repo => + repo.AddAsync(It.Is(f => + f.RequesterId == fromUserId && + f.AddresseeId == toUserId && + f.Status == FriendshipStatus.Pending && + f.Id != Guid.Empty + )), + Times.Once); + } + + // AcceptFriendRequestAsync + [Test] + public async Task AcceptFriendRequestAsync_ValidRequest_CallsUpdateAsync() + { + var friendship = _fixture.Build() + .With(f => f.Status, FriendshipStatus.Pending) + .Create(); + + _friendshipsRepositoryMock + .Setup(r => r.GetByIdAsync(friendship.Id)) + .ReturnsAsync(friendship); + + // Act: вызываем именно Accept, не Send + await _service.AcceptAsync(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.Accepted + )), Times.Once); + } + + [Test] + public void AcceptFriendRequestAsync_Throws_InvalidOperationException_IfStatusNotEqualPending() + { + var friendship = _fixture.Build() + .With(f => f.Status, FriendshipStatus.Accepted) + .Create(); + + _friendshipsRepositoryMock + .Setup(r => r.GetByIdAsync(friendship.Id)) + .ReturnsAsync(friendship); + + // Act & Assert + Assert.ThrowsAsync(async () => await _service.AcceptAsync(friendship.Id, friendship.AddresseeId)); + } + [Test] + public void AcceptFriendRequestAsync_Throws_UnauthorizedAccessException_IfCurrentUserIdIsNotAddressee() + { + // 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.AcceptAsync(friendship.Id, wrongUserId)); + } + + [Test] + public void AcceptFriendRequestAsync_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.AcceptAsync(requestId, userId)); + } + + [Test] + public async Task AcceptFriendRequestAsync_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.AcceptAsync(friendship.Id, friendship.AddresseeId); + + Assert.That(updatedFriendship.Status, Is.EqualTo(FriendshipStatus.Accepted)); + } + + // RejectFriend + [Test] + public async Task RejectFriendRequestAsync_ValidRequest_CallsRemoveAsync() + { + var friendship = _fixture.Build() + .With(f => f.Status, FriendshipStatus.Pending) + .Create(); + + _friendshipsRepositoryMock + .Setup(r => r.GetByIdAsync(friendship.Id)) + .ReturnsAsync(friendship); + + await _service.RejectAsync(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(r => r.GetByIdAsync(friendship.Id)) + .ReturnsAsync(friendship); + + // Act & Assert + Assert.ThrowsAsync(async () => await _service.RejectAsync(friendship.Id, friendship.AddresseeId)); + } + + [Test] + public void RejectFriendRequestAsync_Throws_UnauthorizedAccessException_IfCurrentUserIdIsNotAddressee() + { + // 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.RejectAsync(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.RejectAsync(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.RejectAsync(friendship.Id, friendship.AddresseeId); + + Assert.That(updatedFriendship.Status, Is.EqualTo(FriendshipStatus.Rejected)); + } + + +} \ No newline at end of file diff --git a/Govor.Application.Tests/Services/Friends/FriendRequestQueryServiceTests.cs b/Govor.Application.Tests/Services/Friends/FriendRequestQueryServiceTests.cs new file mode 100644 index 0000000..56029ec --- /dev/null +++ b/Govor.Application.Tests/Services/Friends/FriendRequestQueryServiceTests.cs @@ -0,0 +1,131 @@ +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; +using Moq; + +namespace Govor.Application.Tests.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.Application.Tests/Services/Friends/FriendshipServiceTests.cs b/Govor.Application.Tests/Services/Friends/FriendshipServiceTests.cs new file mode 100644 index 0000000..acd7d08 --- /dev/null +++ b/Govor.Application.Tests/Services/Friends/FriendshipServiceTests.cs @@ -0,0 +1,162 @@ +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; +using Moq; + +namespace Govor.Application.Tests.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 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")); + + // Act + var users = await _service.SearchUsersAsync("test", Guid.NewGuid()); + + // & Assert + Assert.That(users, Is.Empty); + } + + [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 + _usersRepositoryMock + .Setup(u => u.SearchPotentialFriendsAsync(It.IsAny(), "test")) + .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.Application.Tests/Services/Medias/AccesserToDownloadMediaServiceTests.cs b/Govor.Application.Tests/Services/Medias/AccesserToDownloadMediaServiceTests.cs new file mode 100644 index 0000000..8c45613 --- /dev/null +++ b/Govor.Application.Tests/Services/Medias/AccesserToDownloadMediaServiceTests.cs @@ -0,0 +1,294 @@ +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(); + + // ������� ��������� � ����� + 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, + OwnerType = MediaOwnerType.Message, + OwnerId = message.Id + }; + + 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, + OwnerType = MediaOwnerType.Message, + OwnerId = groupMessage.Id + }; + + 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); + } + + [Test] + public async Task HasAccessAsync_ReturnsTrue_ForUserAvatar() + { + var avatarMedia = new MediaFile + { + Id = Guid.NewGuid(), + Url = "/media/avatar.png", + MineType = "image/png", + MediaType = MediaType.Image, + UploaderId = _userId, + OwnerType = MediaOwnerType.Avatar, + OwnerId = _userId, + DateCreated = DateTime.UtcNow + }; + + await _dbContext.MediaFiles.AddAsync(avatarMedia); + await _dbContext.SaveChangesAsync(); + + var result = await _accesser.HasAccessAsync(avatarMedia.Id, _userId); + Assert.That(result, Is.True); + } + + [Test] + public async Task HasAccessAsync_ReturnsTrue_ForOtherUserAvatar() + { + var avatarMedia = new MediaFile + { + Id = Guid.NewGuid(), + Url = "/media/avatar2.png", + MineType = "image/png", + MediaType = MediaType.Image, + UploaderId = _otherUserId, + OwnerType = MediaOwnerType.Avatar, + OwnerId = _otherUserId, + DateCreated = DateTime.UtcNow + }; + + await _dbContext.MediaFiles.AddAsync(avatarMedia); + await _dbContext.SaveChangesAsync(); + + var result = await _accesser.HasAccessAsync(avatarMedia.Id, _userId); + Assert.That(result, Is.True); + } + + [Test] + public async Task HasAccessAsync_ReturnsTrue_ForGroupAvatarMember() + { + var groupAvatar = new MediaFile + { + Id = Guid.NewGuid(), + Url = "/media/group_avatar.png", + MineType = "image/png", + MediaType = MediaType.Image, + UploaderId = _userId, + OwnerType = MediaOwnerType.GroupAvatar, + OwnerId = _groupId, + DateCreated = DateTime.UtcNow + }; + + var membership = new GroupMembership + { + Id = Guid.NewGuid(), + GroupId = _groupId, + UserId = _otherUserId + }; + + await _dbContext.MediaFiles.AddAsync(groupAvatar); + await _dbContext.GroupMemberships.AddAsync(membership); + await _dbContext.SaveChangesAsync(); + + var result = await _accesser.HasAccessAsync(groupAvatar.Id, _otherUserId); + Assert.That(result, Is.True); + } + + [Test] + public async Task HasAccessAsync_ReturnsFalse_ForGroupAvatarNonMember() + { + var groupAvatar = new MediaFile + { + Id = Guid.NewGuid(), + Url = "/media/group_avatar2.png", + MineType = "image/png", + MediaType = MediaType.Image, + UploaderId = _userId, + OwnerType = MediaOwnerType.GroupAvatar, + OwnerId = _groupId, + DateCreated = DateTime.UtcNow + }; + + await _dbContext.MediaFiles.AddAsync(groupAvatar); + await _dbContext.SaveChangesAsync(); + + var unrelatedUserId = Guid.NewGuid(); + var result = await _accesser.HasAccessAsync(groupAvatar.Id, unrelatedUserId); + Assert.That(result, Is.False); + } + + [Test] + public async Task HasAccessAsync_ReturnsTrue_ForSystemMedia() + { + var systemMedia = new MediaFile + { + Id = Guid.NewGuid(), + Url = "/media/system.png", + MineType = "image/png", + MediaType = MediaType.Image, + UploaderId = _userId, + OwnerType = MediaOwnerType.System, + DateCreated = DateTime.UtcNow + }; + + await _dbContext.MediaFiles.AddAsync(systemMedia); + await _dbContext.SaveChangesAsync(); + + var result = await _accesser.HasAccessAsync(systemMedia.Id, Guid.NewGuid()); + Assert.That(result, Is.True); + } + + [Test] + public async Task HasAccessAsync_ReturnsFalse_ForUnknownOwnerType() + { + var unknownMedia = new MediaFile + { + Id = Guid.NewGuid(), + Url = "/media/unknown.png", + MineType = "image/png", + MediaType = MediaType.Image, + UploaderId = _userId, + OwnerType = (MediaOwnerType)999, // ����������� ��� + DateCreated = DateTime.UtcNow + }; + + await _dbContext.MediaFiles.AddAsync(unknownMedia); + await _dbContext.SaveChangesAsync(); + + var result = await _accesser.HasAccessAsync(unknownMedia.Id, _userId); + Assert.That(result, Is.False); + } + + [TearDown] + public void TearDown() + { + _dbContext.Dispose(); + } +} \ No newline at end of file diff --git a/Govor.Application.Tests/Services/Messages/MessageCommandServiceTests.cs b/Govor.Application.Tests/Services/Messages/MessageCommandServiceTests.cs new file mode 100644 index 0000000..e517287 --- /dev/null +++ b/Govor.Application.Tests/Services/Messages/MessageCommandServiceTests.cs @@ -0,0 +1,376 @@ +using Govor.Application.Exceptions.VerifyFriendship; +using Govor.Application.Interfaces; +using Govor.Application.Interfaces.Medias; +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; +using Govor.Core.Repositories.Users; +using Govor.Data.Repositories.Exceptions; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Govor.Application.Tests.Services.Messages; + +[TestFixture] +public class MessageCommandServiceTests +{ + private Mock _mockMessagesRepo; + private Mock _mockUsersRepo; + private Mock _mockGroupsRepo; + private Mock _mockVerifyFriendship; + private Mock _mockPrivateChats; + private Mock _mockMediaService; + private Mock> _mockLogger; + private MessageCommandService _messageService; + + [SetUp] + public void SetUp() + { + _mockMessagesRepo = new Mock(); + _mockUsersRepo = new Mock(); + _mockGroupsRepo = new Mock(); + _mockVerifyFriendship = new Mock(); + _mockPrivateChats = new Mock(); + _mockMediaService = new Mock(); + _mockLogger = new Mock>(); + + _messageService = new MessageCommandService( + _mockMessagesRepo.Object, + _mockUsersRepo.Object, + _mockGroupsRepo.Object, + _mockVerifyFriendship.Object, + _mockPrivateChats.Object, + _mockMediaService.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); + _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 + Assert.That(result, Is.Not.Null); + Assert.That(result.IsSuccess, Is.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_ToUser_When_AttachMediaThrowsException_ReturnsFailure() + { + // Arrange + var senderId = Guid.NewGuid(); + var recipientId = Guid.NewGuid(); + var sendMediaId = Guid.NewGuid(); + + var sendMessageParams = new SendMessage( + "Hello", + null, + recipientId, + RecipientType.User, + senderId, + DateTime.UtcNow, + new List { new SendMedia(sendMediaId, string.Empty) }); + + _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 }); + + // ! + _mockMediaService + .Setup(m => m.AttachToMessageAsync(sendMediaId, It.IsAny())) + .ThrowsAsync(new Exception("Unexpected DB error")); + + // Act + var result = await _messageService.SendMessageAsync(sendMessageParams); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.IsSuccess, Is.False); + Assert.That(result.Exception, Is.Not.Null); + Assert.That(result.Exception.Message, Is.EqualTo("Unexpected DB error")); + + // Проверяем, что сообщение не было сохранено + _mockMessagesRepo.Verify(r => r.AddAsync(It.IsAny()), Times.Never); + + // Проверяем, что метод прикрепления медиа вызывался + _mockMediaService.Verify(m => m.AttachToMessageAsync(sendMediaId, It.IsAny()), 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.Exist(groupId)).Returns(true); + _mockGroupsRepo.Setup(r => r.IsUserMemberOfGroupAsync(senderId, groupId)).ReturnsAsync(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.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.False); + Assert.That(result.Exception, Is.Not.Null); + Assert.That(result.Exception,Is.TypeOf()); + Assert.That(result.Message, Is.Null); + } + + [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.False); + Assert.That(result.Exception, Is.Not.Null); + Assert.That(result.Exception,Is.TypeOf()); + Assert.That(result.Message, Is.Null); + } + + + + // 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.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.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.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.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.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.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.Application.Tests/Services/Messages/MessagesLoaderTests.cs b/Govor.Application.Tests/Services/Messages/MessagesLoaderTests.cs new file mode 100644 index 0000000..4bdf983 --- /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.LoadMessagesInUserChat(_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.LoadMessagesInUserChat(_currentUserId, _otherUserId, null); + + // Assert + Assert.That(result, Is.Empty); + } + + [Test] + public void LoadLastMessagesInUserChat_Throws_WhenUserIdIsEmpty() + { + // Act & Assert + var ex = Assert.ThrowsAsync(async () => + await _loader.LoadMessagesInUserChat(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.LoadMessagesInUserChat(_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.LoadMessagesInChatGroup(_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.LoadMessagesInChatGroup(Guid.Empty, _currentUserId, null)); + + Assert.That(ex.Message, Is.EqualTo("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.LoadMessagesInChatGroup(_groupChatId, _currentUserId, null)); + + Assert.That(ex.Message, Is.EqualTo("You are not a member of this group.")); + } + + [Test] + public async Task LoadLastMessagesInChatGroup_ReturnsEmpty_WhenNoMessages() + { + // Arrange + _groupsRepoMock.Setup(g => g.IsUserMemberOfGroupAsync(_currentUserId, _groupChatId)) + .ReturnsAsync(true); + + // Act + var result = await _loader.LoadMessagesInChatGroup(_groupChatId, _currentUserId, null); + + // Assert + Assert.That(result, Is.Empty); + } + + [TearDown] + public void TearDown() + { + _dbContext.Dispose(); + } +} \ No newline at end of file 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.Application.Tests/Services/PingHandlerServiceTests.cs b/Govor.Application.Tests/Services/PingHandlerServiceTests.cs new file mode 100644 index 0000000..09762ad --- /dev/null +++ b/Govor.Application.Tests/Services/PingHandlerServiceTests.cs @@ -0,0 +1,65 @@ +using Govor.Application.Services; +using Govor.Core.Models.Users; +using Govor.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; + +namespace Govor.Application.Tests.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)); + } + + [TearDown] + public void TearDown() + { + _dbContext?.Dispose(); + _memoryCache?.Dispose(); + } +} diff --git a/Govor.Application.Tests/Services/ProfileServiceTests.cs b/Govor.Application.Tests/Services/ProfileServiceTests.cs new file mode 100644 index 0000000..45e14a6 --- /dev/null +++ b/Govor.Application.Tests/Services/ProfileServiceTests.cs @@ -0,0 +1,144 @@ +using Govor.Application.Services; +using Govor.Core.Models.Users; +using Govor.Core.Repositories.Users; +using Govor.Data.Repositories.Exceptions; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Govor.Application.Tests.Services; + +[TestFixture] +public class ProfileServiceTests +{ + private Mock _mockUsersRepo = null!; + private Mock> _mockLogger = null!; + private ProfileService _service = null!; + private User _testUser = null!; + + [SetUp] + public void SetUp() + { + _mockUsersRepo = new Mock(); + _mockLogger = new Mock>(); + + _service = new ProfileService(_mockUsersRepo.Object, _mockLogger.Object); + + _testUser = new User + { + Id = Guid.NewGuid(), + Username = "testuser", + Description = "old description", + IconId = Guid.NewGuid() + }; + } + + // ---------- GetUserProfileAsync ---------- + + [Test] + public async Task GetUserProfileAsync_ReturnsCorrectData() + { + // Arrange + _mockUsersRepo.Setup(r => r.FindByIdAsync(_testUser.Id)) + .ReturnsAsync(_testUser); + + // Act + var result = await _service.GetUserProfileAsync(_testUser.Id); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.Id, Is.EqualTo(_testUser.Id)); + Assert.That(result.Username, Is.EqualTo("testuser")); + Assert.That(result.Description, Is.EqualTo("old description")); + Assert.That(result.IconId, Is.EqualTo(_testUser.IconId)); + + _mockUsersRepo.Verify(r => r.FindByIdAsync(_testUser.Id), Times.Once); + } + + [Test] + public void GetUserProfileAsync_Throws_WhenUserNotFound() + { + // Arrange + _mockUsersRepo.Setup(r => r.FindByIdAsync(It.IsAny())) + .ReturnsAsync((User)null!); + + // Act + Assert + Assert.ThrowsAsync(() => + _service.GetUserProfileAsync(Guid.NewGuid())); + } + + [Test] + public void GetUserProfileAsync_ThrowsNotFoundException_WhenNotFoundByKeyException() + { + // Arrange + var userId = Guid.NewGuid(); + _mockUsersRepo.Setup(r => r.FindByIdAsync(It.IsAny())) + .ThrowsAsync(new NotFoundByKeyException(userId)); + + // Act + Assert + Assert.ThrowsAsync(() => + _service.GetUserProfileAsync(userId)); + } + // ---------- SetDescription ---------- + + [Test] + public async Task SetDescription_UpdatesUserDescription() + { + // Arrange + var newDescription = "new cool description"; + _mockUsersRepo.Setup(r => r.FindByIdAsync(_testUser.Id)) + .ReturnsAsync(_testUser); + _mockUsersRepo.Setup(r => r.UpdateAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + // Act + await _service.SetDescription(newDescription, _testUser.Id); + + // Assert + Assert.That(_testUser.Description, Is.EqualTo(newDescription)); + _mockUsersRepo.Verify(r => r.UpdateAsync(It.Is(u => u.Description == newDescription)), Times.Once); + } + + [Test] + public void SetDescription_Throws_WhenUserNotFound() + { + // Arrange + _mockUsersRepo.Setup(r => r.FindByIdAsync(It.IsAny())) + .ReturnsAsync((User)null!); + + // Act + Assert + Assert.ThrowsAsync(() => + _service.SetDescription("desc", Guid.NewGuid())); + } + + // ---------- SetNewIcon ---------- + + [Test] + public async Task SetNewIcon_UpdatesUserIconId() + { + // Arrange + var newIconId = Guid.NewGuid(); + _mockUsersRepo.Setup(r => r.FindByIdAsync(_testUser.Id)) + .ReturnsAsync(_testUser); + _mockUsersRepo.Setup(r => r.UpdateAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + // Act + await _service.SetNewIcon(_testUser.Id, newIconId); + + // Assert + Assert.That(_testUser.IconId, Is.EqualTo(newIconId)); + _mockUsersRepo.Verify(r => r.UpdateAsync(It.Is(u => u.IconId == newIconId)), Times.Once); + } + + [Test] + public void SetNewIcon_Throws_WhenUserNotFound() + { + // Arrange + _mockUsersRepo.Setup(r => r.FindByIdAsync(It.IsAny())) + .ReturnsAsync((User)null!); + + // Act + Assert + Assert.ThrowsAsync(() => + _service.SetNewIcon(Guid.NewGuid(), Guid.NewGuid())); + } +} \ No newline at end of file diff --git a/Govor.Application.Tests/Services/UserGroupsServiceTests.cs b/Govor.Application.Tests/Services/UserGroupsServiceTests.cs new file mode 100644 index 0000000..d093df6 --- /dev/null +++ b/Govor.Application.Tests/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.Application.Tests.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.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.Tests/Services/UserOnlineStatus/UserNotificationScopeServiceTests.cs b/Govor.Application.Tests/Services/UserOnlineStatus/UserNotificationScopeServiceTests.cs new file mode 100644 index 0000000..1eb0eb7 --- /dev/null +++ b/Govor.Application.Tests/Services/UserOnlineStatus/UserNotificationScopeServiceTests.cs @@ -0,0 +1,68 @@ +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; + +[TestFixture] +[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 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")); + } +} diff --git a/Govor.Application.Tests/Services/UserSessions/UserSessionOpenerTests.cs b/Govor.Application.Tests/Services/UserSessions/UserSessionOpenerTests.cs new file mode 100644 index 0000000..e09c519 --- /dev/null +++ b/Govor.Application.Tests/Services/UserSessions/UserSessionOpenerTests.cs @@ -0,0 +1,155 @@ +using Govor.Application.Services.UserSessions; +using Govor.Core.Models.Users; +using Govor.Core.Repositories.UserSessionsRepository; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Govor.Application.Interfaces.Authentication; +using Govor.Application.Services.Authentication; +using Govor.Data.Repositories.Exceptions; + +namespace Govor.Application.Tests.Services.UserSessions; + +[TestFixture] +public class UserSessionOpenerTests +{ + private Mock _repositoryMock; + private Mock _jwtServiceMock; + private Mock _jwtTokenHasherMock; + private UserSessionOpener _service; + private User _user; + + private const string DeviceInfo = "Chrome on Windows"; + private const string RefreshToken = "new-refresh-token"; + private const string AccessToken = "new-access-token"; + private const string TokenHash = "hashed-refresh-token"; + + [SetUp] + public void Setup() + { + _repositoryMock = new Mock(); + _jwtServiceMock = new Mock(); + var loggerMock = new Mock>(); + _jwtTokenHasherMock = new Mock(); + var options = Options.Create(new JwtRefreshOption { RefreshTokenLifetimeDays = 30 }); + + _user = new User + { + Id = Guid.NewGuid(), + Username = "test", + Description = "some description", + PasswordHash = "hashed-password", + IconId = Guid.NewGuid(), + CreatedOn = DateOnly.FromDateTime(DateTime.Now), + WasOnline = DateTime.Now, + InviteId = Guid.NewGuid(), + // ... + }; + + _jwtServiceMock.Setup(j => j.GenerateRefreshTokenAsync(_user)).ReturnsAsync(RefreshToken); + _jwtTokenHasherMock.Setup(h => h.HashToken(RefreshToken)).Returns(TokenHash); + + _jwtServiceMock.Setup(j => j.GenerateAccessTokenAsync(_user, It.IsAny())).ReturnsAsync(AccessToken); + + + _service = new UserSessionOpener( + _repositoryMock.Object, + _jwtServiceMock.Object, + _jwtTokenHasherMock.Object, + options, + loggerMock.Object + ); + } + + // UpdateExistingSessionAsync + [Test] + public async Task OpenSessionAsync_ShouldUpdateExistingSession_WhenFoundByDeviceInfo() + { + // Arrange + var existingSessionId = Guid.NewGuid(); + var existingSession = new UserSession + { + Id = existingSessionId, + UserId = _user.Id, + DeviceInfo = DeviceInfo, + RefreshTokenHash = "old-hash", + CreatedAt = DateTime.UtcNow.AddDays(-10), + ExpiresAt = DateTime.UtcNow.AddDays(10), + IsRevoked = true + }; + + _repositoryMock.Setup(r => r.GetByUserIdAsync(_user.Id)).ReturnsAsync(new List { existingSession }); + + // Act + var result = await _service.OpenSessionAsync(_user, DeviceInfo); + + // Assert + Assert.Multiple(() => + { + Assert.That(result.refreshToken, Is.EqualTo(RefreshToken), "Должен быть возвращен новый RefreshToken."); + Assert.That(result.accessToken, Is.EqualTo(AccessToken), "Должен быть возвращен новый AccessToken."); + }); + + _repositoryMock.Verify(r => r.UpdateAsync(It.Is(s => + s.Id == existingSessionId && + s.RefreshTokenHash == TokenHash && + s.IsRevoked == false && + s.ExpiresAt > DateTime.UtcNow + )), Times.Once, "Должен быть вызван метод UpdateAsync с новыми данными."); + + _repositoryMock.Verify(r => r.AddAsync(It.IsAny()), Times.Never); + } + + // CreateNewSessionAsync + + [Test] + public async Task OpenSessionAsync_ShouldCreateNewSession_IfNoneFound() + { + // Arrange + _repositoryMock.Setup(r => r.GetByUserIdAsync(_user.Id)).ReturnsAsync(new List()); + + // Act + var result = await _service.OpenSessionAsync(_user, DeviceInfo); + + // Assert + Assert.Multiple(() => + { + Assert.That(result.refreshToken, Is.EqualTo(RefreshToken), "Должен быть возвращен новый RefreshToken."); + Assert.That(result.accessToken, Is.EqualTo(AccessToken), "Должен быть возвращен новый AccessToken."); + }); + + _repositoryMock.Verify(r => r.AddAsync(It.Is(s => + s.UserId == _user.Id && + s.DeviceInfo == DeviceInfo && + s.RefreshTokenHash == TokenHash && + s.IsRevoked == false + )), Times.Once, "Должен быть вызван метод AddAsync для создания новой сессии."); + + _repositoryMock.Verify(r => r.UpdateAsync(It.IsAny()), Times.Never); + } + + [Test] + public async Task OpenSessionAsync_ShouldCreateNewSession_WhenRepositoryThrowsNotFoundByKeyException() + { + // Arrange + _repositoryMock + .Setup(r => r.GetByUserIdAsync(_user.Id)) + .ThrowsAsync(new NotFoundByKeyException(_user.Id, "userId")); + + // Act + var result = await _service.OpenSessionAsync(_user, DeviceInfo); + + // Assert + Assert.Multiple(() => + { + Assert.That(result.refreshToken, Is.EqualTo(RefreshToken), "Должен быть возвращен новый RefreshToken."); + Assert.That(result.accessToken, Is.EqualTo(AccessToken), "Должен быть возвращен новый AccessToken."); + }); + + _repositoryMock.Verify(r => r.AddAsync(It.Is(s => + s.RefreshTokenHash == TokenHash + )), Times.Once, "Должен быть вызван AddAsync после перехвата исключения."); + + _repositoryMock.Verify(r => r.UpdateAsync(It.IsAny()), Times.Never); + } +} diff --git a/Govor.Application.Tests/Services/UserSessions/UserSessionReaderTests.cs b/Govor.Application.Tests/Services/UserSessions/UserSessionReaderTests.cs new file mode 100644 index 0000000..cc660fa --- /dev/null +++ b/Govor.Application.Tests/Services/UserSessions/UserSessionReaderTests.cs @@ -0,0 +1,75 @@ +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; +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(); + _fixture.Behaviors.OfType() + .ToList() + .ForEach(b => _fixture.Behaviors.Remove(b)); + + _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); + + _mockUserSessionsRepository = new Mock(); + _mockLogger = new Mock>(); + + _userSessionReader = new UserSessionReader(_mockUserSessionsRepository.Object, + _mockLogger.Object); + } + + [Test] + public async Task GetAllUserSessionsAsync_ShouldReturnAllUserSessions() + { + // Arrange + var sessios = _fixture.Build() + .With(f => f.IsRevoked, false) + .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.Tests/Services/UserSessions/UserSessionRefresherTests.cs b/Govor.Application.Tests/Services/UserSessions/UserSessionRefresherTests.cs new file mode 100644 index 0000000..b86877c --- /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 + { + RefreshTokenHash = 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.GetByHashedRefreshTokenAsync(OldRefreshToken)).ReturnsAsync(_session); + _usersRepoMock.Setup(r => r.FindByIdAsync(_user.Id)).ReturnsAsync(_user); + _jwtServiceMock.Setup(j => j.GenerateAccessTokenAsync(_user, _session.Id)).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.RefreshTokenHash == NewRefreshToken && + s.DeviceInfo == _session.DeviceInfo)), Times.Once); + } + + [Test] + public void RefreshTokenAsync_RevokedToken_ThrowsUnauthorizedAccessException() + { + // Arrange + _session.IsRevoked = true; + _sessionsRepoMock.Setup(r => r.GetByHashedRefreshTokenAsync(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.GetByHashedRefreshTokenAsync(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.GetByHashedRefreshTokenAsync(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.Tests/Services/UserSessions/UserSessionRevokerTests.cs b/Govor.Application.Tests/Services/UserSessions/UserSessionRevokerTests.cs new file mode 100644 index 0000000..cd40008 --- /dev/null +++ b/Govor.Application.Tests/Services/UserSessions/UserSessionRevokerTests.cs @@ -0,0 +1,153 @@ +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; +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.Application.Tests/Services/VerifyFriendshipTests.cs b/Govor.Application.Tests/Services/VerifyFriendshipTests.cs new file mode 100644 index 0000000..a4a396d --- /dev/null +++ b/Govor.Application.Tests/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.Application.Tests.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.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/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/Interfaces/AdminsStuff/InvitationGenerator.cs b/Govor.Application/Infrastructure/AdminsStuff/InvitationGenerator.cs similarity index 81% rename from Govor.Application/Interfaces/AdminsStuff/InvitationGenerator.cs rename to Govor.Application/Infrastructure/AdminsStuff/InvitationGenerator.cs index e744b65..f2000b7 100644 --- a/Govor.Application/Interfaces/AdminsStuff/InvitationGenerator.cs +++ b/Govor.Application/Infrastructure/AdminsStuff/InvitationGenerator.cs @@ -1,8 +1,8 @@ -using Govor.API.Services.AdminsStuff.Interfaces; +using Govor.Application.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 { @@ -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/AdminsStuff/UsersService.cs b/Govor.Application/Infrastructure/AdminsStuff/UsersService.cs similarity index 66% rename from Govor.Application/Interfaces/AdminsStuff/UsersService.cs rename to Govor.Application/Infrastructure/AdminsStuff/UsersService.cs index 10f41ec..4651283 100644 --- a/Govor.Application/Interfaces/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 { @@ -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/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/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/Infrastructure/Validators/UsernameValidator.cs b/Govor.Application/Infrastructure/Validators/UsernameValidator.cs index 3d0420d..f38cb4e 100644 --- a/Govor.Application/Infrastructure/Validators/UsernameValidator.cs +++ b/Govor.Application/Infrastructure/Validators/UsernameValidator.cs @@ -1,14 +1,13 @@ -using System.Linq.Expressions; using System.Text.RegularExpressions; 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 { - 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/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 5643ea3..86e24f8 100644 --- a/Govor.Application/Interfaces/Authentication/IInvitesService.cs +++ b/Govor.Application/Interfaces/Authentication/IInvitesService.cs @@ -1,9 +1,10 @@ using Govor.Core.Models; +using Govor.Core.Models.Users; -namespace Govor.API.Services.Authentication.Interfaces; +namespace Govor.Application.Interfaces.Authentication; 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/Interfaces/Authentication/IJwtService.cs b/Govor.Application/Interfaces/Authentication/IJwtService.cs index 96a0c15..d5d54ad 100644 --- a/Govor.Application/Interfaces/Authentication/IJwtService.cs +++ b/Govor.Application/Interfaces/Authentication/IJwtService.cs @@ -1,8 +1,11 @@ -using Govor.Core.Models; +using System.Security.Claims; +using Govor.Core.Models.Users; -namespace Govor.API.Services.Authentication.Interfaces; +namespace Govor.Application.Interfaces.Authentication; public interface IJwtService { - string GenerateJwtToken(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/Interfaces/Authentication/IJwtTokenHasher.cs b/Govor.Application/Interfaces/Authentication/IJwtTokenHasher.cs new file mode 100644 index 0000000..439e87b --- /dev/null +++ b/Govor.Application/Interfaces/Authentication/IJwtTokenHasher.cs @@ -0,0 +1,7 @@ +namespace Govor.Application.Interfaces.Authentication; + +public interface IJwtTokenHasher +{ + string HashToken(string token); + bool VerifyToken(string token, string storedHash); +} \ No newline at end of file diff --git a/Govor.Application/Interfaces/Friends/IFriendRequestCommandService.cs b/Govor.Application/Interfaces/Friends/IFriendRequestCommandService.cs new file mode 100644 index 0000000..3d3c4e4 --- /dev/null +++ b/Govor.Application/Interfaces/Friends/IFriendRequestCommandService.cs @@ -0,0 +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); +} 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/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..e3d5a7c --- /dev/null +++ b/Govor.Application/Interfaces/Friends/IFriendshipService.cs @@ -0,0 +1,9 @@ +using Govor.Core.Models.Users; + +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..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; @@ -7,6 +8,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/IGroupService.cs b/Govor.Application/Interfaces/IGroupService.cs index 955dc32..c3a8a40 100644 --- a/Govor.Application/Interfaces/IGroupService.cs +++ b/Govor.Application/Interfaces/IGroupService.cs @@ -1,8 +1,17 @@ +using Govor.Application.Interfaces.Messages.Parameters; using Govor.Core.Models; +using Govor.Core.Models.Users; -namespace Govor.API.Services; +namespace Govor.Application.Interfaces; 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/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/IMessagesLoader.cs b/Govor.Application/Interfaces/IMessagesLoader.cs new file mode 100644 index 0000000..54fff2f --- /dev/null +++ b/Govor.Application/Interfaces/IMessagesLoader.cs @@ -0,0 +1,9 @@ +using Govor.Core.Models.Messages; + +namespace Govor.Application.Interfaces; + +public interface IMessagesLoader +{ + 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/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/IProfileService.cs b/Govor.Application/Interfaces/IProfileService.cs new file mode 100644 index 0000000..37a717f --- /dev/null +++ b/Govor.Application/Interfaces/IProfileService.cs @@ -0,0 +1,10 @@ +using Govor.Application.Profiles; + +namespace Govor.Application.Interfaces; + +public interface IProfileService +{ + public Task GetUserProfileAsync(Guid userId); + public Task SetDescription(string description, Guid userId); + public Task SetNewIcon(Guid userId, Guid iconId); +} 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/Interfaces/IUsersAdministration.cs b/Govor.Application/Interfaces/IUsersAdministration.cs index f08836f..dfdd34e 100644 --- a/Govor.Application/Interfaces/IUsersAdministration.cs +++ b/Govor.Application/Interfaces/IUsersAdministration.cs @@ -1,8 +1,9 @@ -using Govor.Core.Models; +using Govor.Core.Models.Users; -namespace Govor.API.Services.AdminsStuff.Interfaces; +namespace Govor.Application.Interfaces; public interface IUsersAdministration { Task> GetAllUsersAsync(); + Task GetUserById(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/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 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/Interfaces/Medias/IMediaService.cs b/Govor.Application/Interfaces/Medias/IMediaService.cs new file mode 100644 index 0000000..18769cd --- /dev/null +++ b/Govor.Application/Interfaces/Medias/IMediaService.cs @@ -0,0 +1,25 @@ +using Govor.Core.Models; +using Govor.Core.Models.Messages; + +namespace Govor.Application.Interfaces.Medias; + +public interface IMediaService +{ + public Task UploadMediaAsync(Media file); + public Task DeleteMediaAsync(Guid fileId); + public Task GetMediaByUrlAsync(string url); + public Task GetMediaByIdAsync(Guid mediaId); + Task AttachToMessageAsync(Guid mediaId, Guid messageId); +} + +public record Media(Guid UploaderId, + DateTime UploadedOn, + string FileName, + byte[] Data, + MediaType Type, + string MimeType, + string EncryptedKey, + MediaOwnerType OwnerType, + Guid? OwnerId); + +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 new file mode 100644 index 0000000..b927074 --- /dev/null +++ b/Govor.Application/Interfaces/Messages/IMessageCommandService.cs @@ -0,0 +1,30 @@ +using Govor.Application.Interfaces.Messages.Parameters; +using Govor.Core.Models.Messages; + +namespace Govor.Application.Interfaces.Messages; + +// Combining IChatService and IGroupService functionalities relevant to messages +public interface IMessageCommandService +{ + 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); +} + + + +public record SendMessageResult(bool IsSuccess, Exception? Exception, Message Message) + : Result(IsSuccess, Exception, Message?.Id ?? Guid.Empty); + +public record EditMessageResult(bool IsSuccess, Exception? Exception, Message? OriginalMessage) + : Result(IsSuccess, Exception, OriginalMessage?.Id ?? Guid.Empty) +{ + +} + +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/Result.cs b/Govor.Application/Interfaces/Messages/Parameters/Result.cs new file mode 100644 index 0000000..7151e86 --- /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, Guid messageId); \ 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..e07b452 --- /dev/null +++ b/Govor.Application/Interfaces/Messages/Parameters/SendMedia.cs @@ -0,0 +1,5 @@ +using Govor.Core.Models; + +namespace Govor.Application.Interfaces.Messages.Parameters; + +public record SendMedia(Guid MediaId, string EncryptedKey); \ 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..3f68d10 --- /dev/null +++ b/Govor.Application/Interfaces/Messages/Parameters/SendMessage.cs @@ -0,0 +1,12 @@ +using Govor.Core.Models.Messages; + +namespace Govor.Application.Interfaces.Messages.Parameters; + +public record SendMessage( + string EncryptContent, + Guid? ReplyToMessageId, + Guid RecipientId, + RecipientType RecipientType, + Guid FromUserId, + DateTime SendAt, + IEnumerable Media); \ 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/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/IUserSessionOpener.cs b/Govor.Application/Interfaces/UserSession/IUserSessionOpener.cs new file mode 100644 index 0000000..243688c --- /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..3800917 --- /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/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/Interfaces/UserSession/IUserSessionRevoker.cs b/Govor.Application/Interfaces/UserSession/IUserSessionRevoker.cs new file mode 100644 index 0000000..af256fa --- /dev/null +++ b/Govor.Application/Interfaces/UserSession/IUserSessionRevoker.cs @@ -0,0 +1,8 @@ +namespace Govor.Application.Interfaces.UserSession; + + +public interface IUserSessionRevoker +{ + Task CloseSessionByIdAsync(Guid sessionId, Guid userId); + Task CloseAllSessionsAsync(Guid userId); +} diff --git a/Govor.Application/Profiles/UserProfile.cs b/Govor.Application/Profiles/UserProfile.cs new file mode 100644 index 0000000..10fdda7 --- /dev/null +++ b/Govor.Application/Profiles/UserProfile.cs @@ -0,0 +1,9 @@ +namespace Govor.Application.Profiles; + +public class UserProfile +{ + public Guid Id { get; set; } + public string Username { get; set; } = string.Empty; + public string? Description { get; set; } + public Guid? IconId { get; set; } +} diff --git a/Govor.Application/Services/AuthService.cs b/Govor.Application/Services/Authentication/AuthService.cs similarity index 61% rename from Govor.Application/Services/AuthService.cs rename to Govor.Application/Services/Authentication/AuthService.cs index 8b2d0b1..0b46164 100644 --- a/Govor.Application/Services/AuthService.cs +++ b/Govor.Application/Services/Authentication/AuthService.cs @@ -1,38 +1,33 @@ -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; +namespace Govor.Application.Services.Authentication; public class AuthService : IAccountService { private readonly IPasswordHasher _passwordHasher; - private readonly IJwtService _jwtService; private readonly IUsersRepository _usersRepository; private readonly IAdminsRepository _adminsRepository; private readonly IUsernameValidator _usernameValidator; public AuthService(IUsersRepository usersRepository, - IJwtService jwtService, IPasswordHasher passwordHasher, IAdminsRepository adminsRepository, IUsernameValidator usernameValidator ) { _usersRepository = usersRepository; - _jwtService = jwtService; _passwordHasher = passwordHasher; _adminsRepository = adminsRepository; _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); @@ -48,20 +43,20 @@ public class AuthService : IAccountService PasswordHash = passwordHash, Description = string.Empty, CreatedOn = DateOnly.FromDateTime(DateTime.UtcNow), - IconId = Guid.NewGuid(), + IconId = Guid.Empty, WasOnline = DateTime.UtcNow, InviteId = invitation.Id }; await _usersRepository.AddAsync(user); - SetRole(user, invitation); + await SetRole(user, invitation); - return _jwtService.GenerateJwtToken(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); @@ -71,10 +66,35 @@ public class AuthService : IAccountService if (_passwordHasher.Verify(password, user.PasswordHash) == false) throw new LoginUserException(); - return _jwtService.GenerateJwtToken(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); - private async void SetRole(User user, Invitation invitation) + 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) await _adminsRepository.AddAsync(new Admin() { UserId = user.Id }); diff --git a/Govor.Application/Services/Authentication/JwtAccessOption.cs b/Govor.Application/Services/Authentication/JwtAccessOption.cs new file mode 100644 index 0000000..a898c5e --- /dev/null +++ b/Govor.Application/Services/Authentication/JwtAccessOption.cs @@ -0,0 +1,6 @@ +namespace Govor.Application.Services.Authentication; +public class JwtAccessOption +{ + 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 new file mode 100644 index 0000000..51db7ad --- /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; } +} diff --git a/Govor.Application/Services/Authentication/JwtService.cs b/Govor.Application/Services/Authentication/JwtService.cs new file mode 100644 index 0000000..764ded3 --- /dev/null +++ b/Govor.Application/Services/Authentication/JwtService.cs @@ -0,0 +1,85 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Govor.Application.Interfaces.Authentication; +using Govor.Core.Models.Users; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace Govor.Application.Services.Authentication; + +public class JwtService : IJwtService +{ + private JwtAccessOption _jwtAccessOption; + private JwtRefreshOption _refreshOptions; + private IInvitesService _invitesService; + + public JwtService(IOptions options, IOptions refreshOptions, IInvitesService invitesService) + { + _refreshOptions = refreshOptions.Value; + _jwtAccessOption = options.Value; + _invitesService = invitesService; + } + + 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) + }; + + var singing = new SigningCredentials( + new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtAccessOption.SecretKey)), + SecurityAlgorithms.HmacSha256Signature); + + 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.SecretKey)); + 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.SecretKey)), + 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/Authentication/JwtTokenHasher.cs b/Govor.Application/Services/Authentication/JwtTokenHasher.cs new file mode 100644 index 0000000..0e26f59 --- /dev/null +++ b/Govor.Application/Services/Authentication/JwtTokenHasher.cs @@ -0,0 +1,16 @@ +using Govor.Application.Interfaces.Authentication; + +namespace Govor.Application.Services.Authentication; + +public class JwtTokenHasher : IJwtTokenHasher +{ + public string HashToken(string token) + { + return BCrypt.Net.BCrypt.HashPassword(token, workFactor: 13); + } + + public bool VerifyToken(string token, string storedHash) + { + return BCrypt.Net.BCrypt.Verify(token, storedHash); + } +} \ No newline at end of file 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/Friends/FriendRequestCommandService.cs b/Govor.Application/Services/Friends/FriendRequestCommandService.cs new file mode 100644 index 0000000..3e6b4bb --- /dev/null +++ b/Govor.Application/Services/Friends/FriendRequestCommandService.cs @@ -0,0 +1,83 @@ +using Govor.Application.Exceptions.FriendsService; +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 FriendRequestCommandService : IFriendRequestCommandService +{ + private readonly IFriendshipsRepository _friendshipsRepository; + + public FriendRequestCommandService(IFriendshipsRepository friendshipsRepository) + { + _friendshipsRepository = friendshipsRepository; + } + + public async Task SendAsync(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); + + 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) + { + try + { + var friendship = await _friendshipsRepository.GetByIdAsync(requestId); + + if (friendship.AddresseeId != currentUserId) + throw new UnauthorizedAccessException("You cannot accept this request"); + + if (friendship.Status != FriendshipStatus.Pending) + throw new InvalidOperationException("Request is already accepted"); + + friendship.Status = FriendshipStatus.Accepted; + await _friendshipsRepository.UpdateAsync(friendship); + + return friendship; + } + catch (NotFoundByKeyException ex) + { + throw new InvalidOperationException("Friendship not found! You cant accept request!", ex); + } + } + + public async Task RejectAsync(Guid requestId, Guid currentUserId) + { + try + { + var friendship = await _friendshipsRepository.GetByIdAsync(requestId); + + 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); + return friendship; + } + catch (NotFoundByKeyException ex) + { + throw new InvalidOperationException("Friendship not found! You cant reject request!", 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.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..9944e68 --- /dev/null +++ b/Govor.Application/Services/Friends/FriendshipService.cs @@ -0,0 +1,62 @@ +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; + +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; + } + catch (NotFoundByKeyException<(string, Guid)> ex) + { + return []; + } + catch (NotFoundByKeyException ex) + { + return []; + } + 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("Nothing was found for the specified id.", ex); + } + } +} \ No newline at end of file diff --git a/Govor.Application/Services/FriendsService.cs b/Govor.Application/Services/FriendsService.cs deleted file mode 100644 index b906207..0000000 --- a/Govor.Application/Services/FriendsService.cs +++ /dev/null @@ -1,122 +0,0 @@ -using Govor.Application.Exceptions.FriendsService; -using Govor.Application.Interfaces; -using Govor.Core.Models; -using Govor.Core.Repositories.Friendships; -using Govor.Core.Repositories.Users; -using Govor.Data.Repositories.Exceptions; - -namespace Govor.Application.Services; - -public class FriendsService : IFriendsService -{ - 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); - - 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)) - .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 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() - { - Id = Guid.NewGuid(), - RequesterId = fromUserId, - AddresseeId = toUserId, - Status = FriendshipStatus.Pending - }); - } - - public async Task AcceptFriendRequestAsync(Guid requestId, Guid currentUserId) - { - try - { - var friendship = await _friendshipsRepository.GetByIdAsync(requestId); - - if (friendship.AddresseeId != currentUserId) - throw new UnauthorizedAccessException("You cannot accept this request"); - - if (friendship.Status != FriendshipStatus.Pending) - throw new InvalidOperationException("Request is already accepted"); - - friendship.Status = FriendshipStatus.Accepted; - await _friendshipsRepository.UpdateAsync(friendship); - } - catch (NotFoundByKeyException e) - { - throw new InvalidOperationException("Friendship not found! You cant accept request!", e); - } - } - - 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); - } - } - - public async Task> GetIncomingRequestsAsync(Guid userId) - { - try - { - var user = await _usersRepository.FindByIdAsync(userId); - return user.ReceivedFriendRequests - .Where(f => f.Status == FriendshipStatus.Pending) - .ToList(); - } - catch (NotFoundByKeyException ex) - { - throw new InvalidOperationException("User not exist", ex); - } - } -} - diff --git a/Govor.Application/Services/InvitesService.cs b/Govor.Application/Services/InvitesService.cs index 4942535..f281025 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.Application.Interfaces.Authentication; using Govor.Core.Models; +using Govor.Core.Models.Users; using Govor.Core.Repositories.Invaites; using Govor.Data.Repositories.Exceptions; @@ -15,7 +16,7 @@ public class InvitesService : IInvitesService _invitesRepository = invitesRepository; } - public async Task GetRole(User user) + public async Task GetRoleAsync(User user) { try { @@ -28,24 +29,25 @@ 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/JwtOption.cs b/Govor.Application/Services/JwtOption.cs deleted file mode 100644 index 144c231..0000000 --- a/Govor.Application/Services/JwtOption.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Govor.Application.Services; -public class JwtOption -{ - public string SecretKeу {get; set;} - public int Hours { get; set; } -} \ No newline at end of file diff --git a/Govor.Application/Services/JwtService.cs b/Govor.Application/Services/JwtService.cs deleted file mode 100644 index f4787ee..0000000 --- a/Govor.Application/Services/JwtService.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using System.Text; -using Govor.API.Services.Authentication.Interfaces; -using Govor.Core.Models; -using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.Tokens; - -namespace Govor.Application.Services; - -public class JwtService : IJwtService -{ - private JwtOption _jwtOption; - private IInvitesService _invitesService; - - public JwtService(IOptions options, IInvitesService invitesService) - { - _jwtOption = options.Value; - _invitesService = invitesService; - } - - public string GenerateJwtToken(User user) - { - var claims = new[] - { - new Claim("userID", user.Id.ToString()), - new Claim(ClaimTypes.Role, _invitesService.GetRole(user).Result, ClaimValueTypes.String) - }; - - var singing = new SigningCredentials( - new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOption.SecretKeу)), - SecurityAlgorithms.HmacSha256Signature); - - var token = new JwtSecurityToken(signingCredentials: singing, - expires: DateTime.UtcNow.AddHours(_jwtOption.Hours), - claims: claims); - - return new JwtSecurityTokenHandler().WriteToken(token); - } -} \ No newline at end of file diff --git a/Govor.Application/Services/LocalStorageService.cs b/Govor.Application/Services/LocalStorageService.cs index 2b7e1fc..c7ebb3a 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 @@ -33,7 +32,7 @@ public class LocalStorageService : IStorageService var fullPath = Path.Combine(folder, uniqueFileName); await using var stream = new FileStream(fullPath, FileMode.Create); - stream.WriteAsync(data, 0, data.Length); + await stream.WriteAsync(data, 0, data.Length); return Path.Combine(date, uniqueFileName); } diff --git a/Govor.Application/Services/Medias/AccesserToDownloadMediaService.cs b/Govor.Application/Services/Medias/AccesserToDownloadMediaService.cs new file mode 100644 index 0000000..c92c024 --- /dev/null +++ b/Govor.Application/Services/Medias/AccesserToDownloadMediaService.cs @@ -0,0 +1,48 @@ +using Govor.Application.Interfaces.Medias; +using Govor.Core.Models; +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 mediaId, Guid userId) + { + var media = await _dbContext.MediaFiles + .AsNoTracking() + .Where(m => m.Id == mediaId) + .Select(m => new { m.OwnerType, m.OwnerId, m.UploaderId }) + .FirstOrDefaultAsync(); + + if (media is null) + return false; + + return media.OwnerType switch + { + MediaOwnerType.Avatar => true, // media.OwnerId == userId + MediaOwnerType.GroupAvatar => await _dbContext.GroupMemberships + .AnyAsync(gm => gm.GroupId == media.OwnerId && gm.UserId == userId), + + MediaOwnerType.Message => await _dbContext.MediaAttachments + .AnyAsync(ma => + ma.MediaFileId == mediaId && + ( + ma.Message.SenderId == userId || + ma.Message.RecipientId == userId || + _dbContext.GroupMemberships.Any(gm => + gm.GroupId == ma.Message.RecipientId && gm.UserId == userId) + )), + + MediaOwnerType.System => true, + _ => false + }; + } +} \ No newline at end of file diff --git a/Govor.Application/Services/Medias/MediaService.cs b/Govor.Application/Services/Medias/MediaService.cs new file mode 100644 index 0000000..226cacc --- /dev/null +++ b/Govor.Application/Services/Medias/MediaService.cs @@ -0,0 +1,123 @@ +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.Medias; + +public class MediaService : IMediaService +{ + private ILogger _logger; + private IStorageService _storageService; + private GovorDbContext _dbContext; + + public MediaService(IStorageService storageService, GovorDbContext dbContext, ILogger logger) + { + _storageService = storageService; + _dbContext = dbContext; + _logger = logger; + } + + public async Task UploadMediaAsync(Media file) + { + 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.MimeType, + Url = url, + OwnerType = file.OwnerType, + OwnerId = file.OwnerId, + }); + + 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) + { + throw new NotImplementedException(); + } + + 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 by given id {mediaId}"); + + // Загрузить бинарные данные из хранилища + 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, + mediaFile.MediaType.ToString(), + contentBytes, + mediaFile.MediaType, + mediaFile.MineType, + string.Empty, + mediaFile.OwnerType, + mediaFile.OwnerId + ); + } + catch (FileNotFoundException ex) + { + _logger.LogWarning(ex, "Media file not found on storage."); + throw; + } + } + public async Task AttachToMessageAsync(Guid mediaId, Guid messageId) + { + var mediaFile = await _dbContext.MediaFiles + .FirstOrDefaultAsync(x => x.Id == mediaId) + ?? throw new KeyNotFoundException($"No media found by given id {mediaId}"); + + if (mediaFile.OwnerType != MediaOwnerType.Message) + { + _logger.LogWarning("Attempt to attach already owned media {MediaId}", mediaId); + throw new InvalidOperationException($"Media {mediaId} is already attached to {mediaFile.OwnerType}"); + } + + mediaFile.OwnerType = MediaOwnerType.Message; + mediaFile.OwnerId = messageId; + + _dbContext.MediaFiles.Update(mediaFile); + await _dbContext.SaveChangesAsync(); + + _logger.LogInformation("Media {MediaId} successfully attached to message {MessageId}", mediaId, messageId); + } +} \ No newline at end of file diff --git a/Govor.Application/Services/Messages/MessageCommandService.cs b/Govor.Application/Services/Messages/MessageCommandService.cs new file mode 100644 index 0000000..40c1914 --- /dev/null +++ b/Govor.Application/Services/Messages/MessageCommandService.cs @@ -0,0 +1,233 @@ +using Govor.Application.Interfaces; +using Govor.Application.Interfaces.Medias; +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; + +public class MessageCommandService : IMessageCommandService +{ + private readonly IMessagesRepository _messagesRepository; + private readonly IUsersRepository _usersRepository; + private readonly IGroupsRepository _groupsRepository; + private readonly IPrivateChatsRepository _privateChats; + private readonly IVerifyFriendship _verifyFriendship; + private readonly IMediaService _mediaService; + private readonly ILogger _logger; + + public MessageCommandService( + IMessagesRepository messagesRepository, + IUsersRepository usersRepository, + IGroupsRepository groupsRepository, + IVerifyFriendship verifyFriendship, + IPrivateChatsRepository privateChats, + IMediaService mediaService, + ILogger logger) + { + _messagesRepository = messagesRepository; + _usersRepository = usersRepository; + _groupsRepository = groupsRepository; + _verifyFriendship = verifyFriendship; + _privateChats = privateChats; + _mediaService = mediaService; + _logger = logger; + } + + public async Task SendMessageAsync(SendMessage sendParams) + { + Guid recipientId; + try + { + // Validate recipient + if (sendParams.RecipientType == RecipientType.User) + { + 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."), default); + } + // 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) + { + 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); + } + // 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."), 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 = recipientId, + RecipientType = sendParams.RecipientType, + EncryptedContent = sendParams.EncryptContent, + SentAt = sendParams.SendAt, + IsEdited = false, + ReplyToMessageId = sendParams.ReplyToMessageId, + MediaAttachments = sendParams.Media?.Select(m => new MediaAttachments + { + Id = Guid.NewGuid(), + MessageId = messageId, + MediaFileId = m.MediaId + }).ToList() ?? new List() + }; + + // If there is media, we link them. + if (sendParams.Media != null && sendParams.Media.Any()) + { + foreach (var media in sendParams.Media) + { + await _mediaService.AttachToMessageAsync(media.MediaId, messageId); + } + } + + 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, 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, default); + } + } + + 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 + { + 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); + } + + /*if (message.SentAt < DateTime.UtcNow.AddMinutes(-15)) + throw new Exception("Edit time limit exceeded");*/ + + var originalMessageForNotification = new Message + { + Id = message.Id, + SenderId = message.SenderId, + RecipientId = message.RecipientId, + RecipientType = message.RecipientType, + SentAt = message.SentAt, + ReplyToMessageId = message.ReplyToMessageId, + Reactions = message.Reactions, + MediaAttachments = message.MediaAttachments, + MessageViews = message.MessageViews, + }; + + 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, default, originalMessageForNotification); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error editing message {MessageId} by user {EditorId}", editParams.MessageId, editParams.EditorId); + return new EditMessageResult(false, ex, default); + } + } + + 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."), default); + // } + } + + var originalMessageForNotification = new Message + { + Id = message.Id, + SenderId = message.SenderId, + RecipientId = message.RecipientId, + RecipientType = message.RecipientType, + }; + + await _messagesRepository.RemoveAsync(deleteParams.MessageId); + + _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); + return new DeleteMessageResult(false, ex, default); + } + } +} \ No newline at end of file diff --git a/Govor.Application/Services/Messages/MessagesLoader.cs b/Govor.Application/Services/Messages/MessagesLoader.cs new file mode 100644 index 0000000..872084b --- /dev/null +++ b/Govor.Application/Services/Messages/MessagesLoader.cs @@ -0,0 +1,144 @@ +using Govor.Application.Interfaces; +using Govor.Core.Models.Messages; +using Govor.Core.Repositories.Groups; +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 IGroupsRepository _groupsRepository; + private IPrivateChatsRepository _privateChatsRepository; + private GovorDbContext _dbContext; + + public MessagesLoader( + IGroupsRepository groupsRepository, + IPrivateChatsRepository privateChatsRepository, + GovorDbContext dbContext) + { + _groupsRepository = groupsRepository; + _privateChatsRepository = privateChatsRepository; + _dbContext = dbContext; + } + + public async Task> LoadMessagesInUserChat( + Guid userId, + Guid currentUser, + Guid? startMessageId, + int before = 20, + int after = 2) + { + if (userId == Guid.Empty) + throw new ArgumentException("User id cannot be empty"); + + if (!_privateChatsRepository.Exist(userId, currentUser)) + throw new InvalidOperationException("Private chat not found"); + + 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) + { + 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> LoadMessagesInChatGroup( + Guid chatId, + Guid currentUser, + Guid? startMessageId, + int before = 20, + int after = 2) + { + if (chatId == Guid.Empty) + throw new ArgumentException("Chat id cannot be empty"); + + 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 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.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/ProfileService.cs b/Govor.Application/Services/ProfileService.cs new file mode 100644 index 0000000..0ec2073 --- /dev/null +++ b/Govor.Application/Services/ProfileService.cs @@ -0,0 +1,57 @@ +using Govor.Application.Interfaces; +using Govor.Application.Profiles; +using Govor.Core.Repositories.Users; +using Govor.Data.Repositories.Exceptions; +using Microsoft.Extensions.Logging; + +namespace Govor.Application.Services; + +public class ProfileService : IProfileService +{ + private readonly ILogger _logger; + private readonly IUsersRepository _userRepository; + + public ProfileService(IUsersRepository userRepository, ILogger logger) + { + _userRepository = userRepository; + _logger = logger; + } + + public async Task GetUserProfileAsync(Guid userId) + { + _logger.LogInformation("Gettings user {userId} profile", userId); + try + { + var user = await _userRepository.FindByIdAsync(userId); + + return new UserProfile + { + Id = user.Id, + Username = user.Username, + Description = user.Description, + IconId = user.IconId + }; + } + catch (NotFoundByKeyException ex) + { + throw new NotFoundException("User's profile cant be found with given id", ex); + } + + } + + public async Task SetDescription(string description, Guid userId) + { + var user = await _userRepository.FindByIdAsync(userId); + + user.Description = description; + await _userRepository.UpdateAsync(user); + } + + public async Task SetNewIcon(Guid userId, Guid iconId) + { + var user = await _userRepository.FindByIdAsync(userId); + + user.IconId = iconId; + await _userRepository.UpdateAsync(user); + } +} 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 diff --git a/Govor.Application/Services/UserOnlineStatus/OnlineUserStore.cs b/Govor.Application/Services/UserOnlineStatus/OnlineUserStore.cs new file mode 100644 index 0000000..2100472 --- /dev/null +++ b/Govor.Application/Services/UserOnlineStatus/OnlineUserStore.cs @@ -0,0 +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) + { + _onlineUsers[userId] = DateTime.UtcNow; + } + + public void SetOfflineUser(Guid userId) + { + _onlineUsers.TryRemove(userId, out _); + } + + public bool IsOnline(Guid userId) + { + return _onlineUsers.ContainsKey(userId); + } + + public IReadOnlyCollection GetAllOnlineUsers() + { + return _onlineUsers.Keys.ToList(); + } +} 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.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.Application/Services/UserSessions/UserSessionOpener.cs b/Govor.Application/Services/UserSessions/UserSessionOpener.cs new file mode 100644 index 0000000..7dda30f --- /dev/null +++ b/Govor.Application/Services/UserSessions/UserSessionOpener.cs @@ -0,0 +1,103 @@ +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 Govor.Data.Repositories.Exceptions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Govor.Application.Services.UserSessions; + +public class UserSessionOpener : IUserSessionOpener +{ + private readonly IUserSessionsRepository _repository; + private readonly ILogger _logger; + private readonly IJwtTokenHasher _jwtTokenHasher; + private readonly JwtRefreshOption _options; + private readonly IJwtService _jwtService; + + public UserSessionOpener( + IUserSessionsRepository repository, + IJwtService jwtService, + IJwtTokenHasher jwtTokenHasher, + IOptions options, + ILogger logger) + { + _jwtService = jwtService; + _repository = repository; + _jwtTokenHasher = jwtTokenHasher; + _logger = logger; + _options = options.Value; + } + + public async Task OpenSessionAsync(User user, string deviceInfo) + { + _logger.LogInformation($"Opening session for user {user.Id} on device '{deviceInfo}'"); + + try + { + var sessions = await _repository.GetByUserIdAsync(user.Id); + var existingSession = sessions.FirstOrDefault(s => s.DeviceInfo == deviceInfo); + + if (existingSession is not null) + return await UpdateExistingSessionAsync(user, deviceInfo, existingSession); + } + catch (NotFoundByKeyException ex) + { + + } + + return await CreateNewSessionAsync(user, deviceInfo); + } + + private async Task UpdateExistingSessionAsync(User user, string deviceInfo, UserSession session) + { + var (accessToken, refreshToken) = await GenerateTokensAsync(user, session.Id); + + var newTokenHash = _jwtTokenHasher.HashToken(refreshToken); + var newExpiresAt = DateTime.UtcNow.AddDays(_options.RefreshTokenLifetimeDays); + + session.RefreshTokenHash = newTokenHash; + 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(refreshToken, accessToken); + } + + private async Task CreateNewSessionAsync(User user, string deviceInfo) + { + var sessionId = Guid.NewGuid(); + var (accessToken, refreshToken) = await GenerateTokensAsync(user, sessionId); + + var refreshTokenHash = _jwtTokenHasher.HashToken(refreshToken); + + var newSession = new UserSession + { + Id = sessionId, + UserId = user.Id, + DeviceInfo = deviceInfo, + RefreshTokenHash = refreshTokenHash, + CreatedAt = DateTime.UtcNow, + ExpiresAt = DateTime.UtcNow.AddDays(_options.RefreshTokenLifetimeDays), + IsRevoked = false + }; + + await _repository.AddAsync(newSession); + + _logger.LogInformation($"Created new session {sessionId} for user {user.Id} on device '{deviceInfo}'"); + + return new RefreshResult(refreshToken, accessToken); + } + + private async Task<(string accessToken, string refreshToken)> GenerateTokensAsync(User user, Guid sessionId) + { + var accessToken = await _jwtService.GenerateAccessTokenAsync(user, sessionId); + var refreshToken = await _jwtService.GenerateRefreshTokenAsync(user); + return (accessToken, refreshToken); + } +} \ No newline at end of file diff --git a/Govor.Application/Services/UserSessions/UserSessionReader.cs b/Govor.Application/Services/UserSessions/UserSessionReader.cs new file mode 100644 index 0000000..f9ba04c --- /dev/null +++ b/Govor.Application/Services/UserSessions/UserSessionReader.cs @@ -0,0 +1,34 @@ +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 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.Where(f => !f.IsRevoked).ToList() ?? []; + } + catch (NotFoundByKeyException ex) + { + _logger.LogWarning("The user has no sessions."); + return []; + } + } +} \ 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..10f359b --- /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.Users; +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.GetByHashedRefreshTokenAsync(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, session.Id); + var newRefreshToken = await _jwtService.GenerateRefreshTokenAsync(user); + + // Opening new session + var newSession = new UserSession + { + UserId = user.Id, + RefreshTokenHash = 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.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 diff --git a/Govor.Application/Services/VerifierFriendship.cs b/Govor.Application/Services/VerifierFriendship.cs new file mode 100644 index 0000000..1a57659 --- /dev/null +++ b/Govor.Application/Services/VerifierFriendship.cs @@ -0,0 +1,72 @@ +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; + 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) + { + _friendshipsRepository = friendshipsRepository ?? throw new ArgumentNullException(nameof(friendshipsRepository)); + _logger = logger; + } + + public async Task VerifyAsync(Guid targetUserId, Guid friendUserId) + { + try + { + 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("hello"); + + _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); + } + } + + public async Task TryVerifyAsync(Guid targetUserId, Guid friendUserId) + { + try + { + await VerifyAsync(targetUserId, friendUserId); + return true; + } + catch (Exception ex) + { + return false; + } + } +} \ No newline at end of file diff --git a/Govor.Console/FriendsClient.cs b/Govor.Console/FriendsClient.cs deleted file mode 100644 index 21d327a..0000000 --- a/Govor.Console/FriendsClient.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.Net.Http; -using System.Net.Http.Json; -using Govor.Contracts.DTOs; - -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(); - } - - 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(); - } - - public async Task AcceptFriendRequestAsync(Guid requesterId) - { - var content = JsonContent.Create(requesterId); - var response = await _client.PostAsync("/api/friends/accept", content); - response.EnsureSuccessStatusCode(); - } - - public async Task> GetFriendsAsync() - { - var response = await _client.GetAsync("/api/friends"); - response.EnsureSuccessStatusCode(); - - return await response.Content.ReadFromJsonAsync>() ?? new List(); - } - } - -} diff --git a/Govor.Console/Govor.Console.csproj b/Govor.Console/Govor.Console.csproj deleted file mode 100644 index 49d4e74..0000000 --- a/Govor.Console/Govor.Console.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net9.0 - 13 - enable - enable - - - - - - - - - - - - - - diff --git a/Govor.Console/HttpClientService.cs b/Govor.Console/HttpClientService.cs deleted file mode 100644 index 93aa10f..0000000 --- a/Govor.Console/HttpClientService.cs +++ /dev/null @@ -1,65 +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 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 817720a..0000000 --- a/Govor.Console/Program.cs +++ /dev/null @@ -1,215 +0,0 @@ -using Microsoft.AspNetCore.SignalR.Client; -using System.IdentityModel.Tokens.Jwt; -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Text.Json; -using Govor.Contracts.Requests; - - -/*==================================== - *Личные сообщения| Егор - *==================================== - * [15:59] Вы добавили (Егор) в друзья - * - * [16:30] Егор >> Привет! - * [16:31] Ты >> Че как? - * - *------------------------------------ - * >> "Пойдем завтра гулять?!" - */ - -namespace Govor.ConsoleClient -{ - class Program - { - static string? AuthToken = null; - static HttpClientService HttpService = new("https://localhost:7155"); // поменяй URL на свой - private static FriendsClient friendsClient; - static Dictionary> ChatHistory = new(); - static string CurrentChatUser = null; - - static async Task Main() - { - LoginWindow(); - - while (true) - { - Console.Write(CurrentChatUser == null ? "/> " : ">> "); - var input = Console.ReadLine(); - - if (string.IsNullOrWhiteSpace(input)) continue; - - if (input.StartsWith("/")) - HandleCommand(input); - else if (CurrentChatUser != null) - SendMessage(input); - else - Console.WriteLine("[Система] Введите команду, например /friends"); - } - } - - static async void HandleCommand(string input) - { - var args = input.Split(' ', 2); - var command = args[0].ToLower(); - var argument = args.Length > 1 ? args[1] : null; - - switch (command) - { - 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("https://localhost:7155"); - sharedClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", AuthToken); - - friendsClient = new FriendsClient(sharedClient); - 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("https://localhost:7155"); - sharedClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", AuthToken); - - friendsClient = new FriendsClient(sharedClient); - Console.WriteLine("[Успех] Регистрация завершена. Токен сохранен."); - } - catch (Exception ex) - { - Console.WriteLine($"[Ошибка] {ex.Message}"); - } - break; - case "/search": - Console.Write("Введите имя друга: "); - var q = Console.ReadLine(); - var foundUsers = await friendsClient.SearchAsync(q); - foreach (var user in foundUsers) - { - Console.WriteLine($"{user.Username} [{user.Id}]"); - } - break; - - case "/exit": - Console.WriteLine("[Система] Выход..."); - Environment.Exit(0); - break; - - case "/friends": - var friends = await friendsClient.GetFriendsAsync(); - foreach (var f in friends) - { - Console.WriteLine($"{f.Username} | был онлайн: {f.WasOnline}"); - } - break; - case "/friend": - Console.Write("Введите ID пользователя, которому хотите отправить запрос: "); - var targetId = Guid.Parse(Console.ReadLine()); - await friendsClient.SendFriendRequestAsync(targetId); - Console.WriteLine("Запрос отправлен"); - break; - - case "/accept": - Console.Write("Введите ID пользователя, чью заявку принимаете: "); - var acceptId = Guid.Parse(Console.ReadLine()); - await friendsClient.AcceptFriendRequestAsync(acceptId); - Console.WriteLine("Принято"); - break; - case "/incoming": - var requests = await friendsClient.GetIncomingRequestsAsync(); - foreach (var r in requests) - { - Console.WriteLine($"Запрос от: {r.RequesterId} (добавьте через /accept {r.RequesterId})"); - } - break; - case "/chat": - if (string.IsNullOrEmpty(argument)) - { - Console.WriteLine("[Ошибка] Укажите имя друга"); - break; - } - - CurrentChatUser = argument; - ShowChat(argument); - break; - - case "/back": - CurrentChatUser = null; - break; - - default: - Console.WriteLine("[Ошибка] Неизвестная команда"); - break; - } - } - - static void ShowChat(string user) - { - Console.Clear(); - Console.WriteLine($"/*===================================="); - Console.WriteLine($" * Личные сообщения | {user}"); - Console.WriteLine($" *===================================="); - - if (!ChatHistory.ContainsKey(user)) - { - ChatHistory[user] = new List - { - $"[15:59] Вы добавили ({user}) в друзья", - $"[16:30] {user} >> Привет!", - $"[16:31] Ты >> Че как?" - }; - } - - foreach (var msg in ChatHistory[user]) - Console.WriteLine(" * " + msg); - - Console.WriteLine(" *------------------------------------"); - } - - static void SendMessage(string message) - { - if (string.IsNullOrWhiteSpace(message)) return; - - ChatHistory[CurrentChatUser].Add($"Ты >> {message}"); - Console.WriteLine($">> {message}"); - } - - static void LoginWindow() - { - Console.WriteLine($"/*===================================="); - Console.WriteLine($" * Добро пожаловать в Govor!"); - Console.WriteLine($" *===================================="); - Console.WriteLine(" * /reg - создать аккаунт "); - Console.WriteLine(" * /login - войти"); - } - } -} - - -public class LoginResponse -{ - public string token { get; set; } -} \ No newline at end of file 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 new file mode 100644 index 0000000..8b2a59a --- /dev/null +++ b/Govor.ConsoleClient/App.cs @@ -0,0 +1,28 @@ +using Govor.ConsoleClient.Services; +using Govor.ConsoleClient.Services.Interfaces; + +namespace Govor.ConsoleClient; + +public class App +{ + private readonly IInputPipeline _inputPipeline; + private readonly ILogger _logger; + + public App(IInputPipeline 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..f0fa612 --- /dev/null +++ b/Govor.ConsoleClient/Commands/HelpCommand.cs @@ -0,0 +1,56 @@ +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) + { + 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() => "Необходима для получения информации о доступных командах\nЧтобы получить подробную информацию о команде, напишите /help {command}"; + + public string ShortHelp() => "Необходима для получения информации о доступных командах"; +} 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..56d4528 --- /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() + { + return "Отпарвка тестовых сообщений не существующему юзеру 2"; + } + + public string ShortHelp() + { + return "Отпарвка тестовых сообщений не существующему юзеру"; + } +} \ No newline at end of file diff --git a/Govor.ConsoleClient/DependencyInjection.cs b/Govor.ConsoleClient/DependencyInjection.cs new file mode 100644 index 0000000..15f06f5 --- /dev/null +++ b/Govor.ConsoleClient/DependencyInjection.cs @@ -0,0 +1,46 @@ +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; + +namespace Govor.ConsoleClient; + +public static class DependencyInjection +{ + public static IServiceProvider Configure() + { + var services = new ServiceCollection(); + + // Регистрация команд + services.AddCommands(); + + // Сервисы + services.AddApplicationServices(); + + 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.ConsoleClient/Govor.ConsoleClient.csproj b/Govor.ConsoleClient/Govor.ConsoleClient.csproj new file mode 100644 index 0000000..e3de400 --- /dev/null +++ b/Govor.ConsoleClient/Govor.ConsoleClient.csproj @@ -0,0 +1,30 @@ + + + + Exe + net8.0 + 12 + enable + enable + + + + + + + + + + + + + + + + + + + + + + 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/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/Implementations/CommandDispatcher.cs b/Govor.ConsoleClient/Services/Implementations/CommandDispatcher.cs new file mode 100644 index 0000000..52b440d --- /dev/null +++ b/Govor.ConsoleClient/Services/Implementations/CommandDispatcher.cs @@ -0,0 +1,44 @@ +using System.Reflection; +using Govor.ConsoleClient.Commands; +using Govor.ConsoleClient.Services.Interfaces; + +namespace Govor.ConsoleClient.Services.Implementations; + +public class CommandDispatcher : ICommandDispatcher +{ + private readonly Dictionary _commands = new(); + private readonly ILogger _logger; + private readonly IMiddlewarePipeline _pipeline; + + public CommandDispatcher(IEnumerable commands, ILogger logger, IMiddlewarePipeline 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; + } + } + + public IEnumerable GetAllCommands() => _commands.Values; +} \ No newline at end of file diff --git a/Govor.ConsoleClient/Services/Implementations/ConsoleLogger.cs b/Govor.ConsoleClient/Services/Implementations/ConsoleLogger.cs new file mode 100644 index 0000000..86d22c3 --- /dev/null +++ b/Govor.ConsoleClient/Services/Implementations/ConsoleLogger.cs @@ -0,0 +1,47 @@ +using Govor.ConsoleClient.Services.Interfaces; + +namespace Govor.ConsoleClient.Services.Implementations; + +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/Implementations/InputPipeline.cs b/Govor.ConsoleClient/Services/Implementations/InputPipeline.cs new file mode 100644 index 0000000..996fbe6 --- /dev/null +++ b/Govor.ConsoleClient/Services/Implementations/InputPipeline.cs @@ -0,0 +1,50 @@ +using Govor.ConsoleClient.Commands; +using Govor.ConsoleClient.Services.Interfaces; + +namespace Govor.ConsoleClient.Services.Implementations; + +public class InputPipeline : IInputPipeline +{ + private readonly ICommandDispatcher _dispatcher; + private readonly ILogger _logger; + private IInteractiveCommand? _activeCommand; + + public InputPipeline(ICommandDispatcher 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 the command supports interactivity, save it as active + 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($"Введите /help, чтобы узнать доступные команды!"); + } + } + } +} \ No newline at end of file diff --git a/Govor.ConsoleClient/Services/Implementations/MiddlewarePipeline.cs b/Govor.ConsoleClient/Services/Implementations/MiddlewarePipeline.cs new file mode 100644 index 0000000..4cc7da1 --- /dev/null +++ b/Govor.ConsoleClient/Services/Implementations/MiddlewarePipeline.cs @@ -0,0 +1,30 @@ +using Govor.ConsoleClient.Services.Interfaces; +using Govor.ConsoleClient.Services.Middleware; + +namespace Govor.ConsoleClient.Services.Implementations; + +public delegate Task CommandMiddleware(CommandContext context, Func next); + +public class MiddlewarePipeline : IMiddlewarePipeline +{ + 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.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/Interfaces/ILogger.cs b/Govor.ConsoleClient/Services/Interfaces/ILogger.cs new file mode 100644 index 0000000..15136b0 --- /dev/null +++ b/Govor.ConsoleClient/Services/Interfaces/ILogger.cs @@ -0,0 +1,10 @@ +namespace Govor.ConsoleClient.Services.Interfaces; + +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/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 new file mode 100644 index 0000000..b105e77 --- /dev/null +++ b/Govor.ConsoleClient/Services/Middleware/ExceptionHandlingMiddleware.cs @@ -0,0 +1,25 @@ +using Govor.ConsoleClient.Services.Interfaces; + +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/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.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/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; } +} 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/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.Contracts/DTOs/UserProfileDto.cs b/Govor.Contracts/DTOs/UserProfileDto.cs new file mode 100644 index 0000000..6d8acfd --- /dev/null +++ b/Govor.Contracts/DTOs/UserProfileDto.cs @@ -0,0 +1,9 @@ +namespace Govor.Contracts.DTOs; + +public class UserProfileDto +{ + public Guid Id { get; set; } + public string Username { get; set; } = string.Empty; + public string? Description { get; set; } + public Guid? IconId { get; set; } +} diff --git a/Govor.Contracts/Govor.Contracts.csproj b/Govor.Contracts/Govor.Contracts.csproj index 4f3150c..6427a81 100644 --- a/Govor.Contracts/Govor.Contracts.csproj +++ b/Govor.Contracts/Govor.Contracts.csproj @@ -1,7 +1,7 @@  - net9.0 + net8.0 enable enable @@ -10,4 +10,7 @@ + + + diff --git a/Govor.Contracts/Requests/AvatarUploadRequest.cs b/Govor.Contracts/Requests/AvatarUploadRequest.cs new file mode 100644 index 0000000..3ae6553 --- /dev/null +++ b/Govor.Contracts/Requests/AvatarUploadRequest.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; +using Govor.Core.Models; +using Govor.Core.Models.Messages; +using Microsoft.AspNetCore.Http; + +namespace Govor.Contracts.Requests; + +public class AvatarUploadRequest +{ + [Required] + public IFormFile FromFile { get; set; } + [Required] + public MediaType Type { get; set; } + [Required, MaxLength(255)] + public string MimeType { get; set; } = string.Empty; +} \ No newline at end of file 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 new file mode 100644 index 0000000..ec62161 --- /dev/null +++ b/Govor.Contracts/Requests/MediaUploadRequest.cs @@ -0,0 +1,20 @@ +using Govor.Core.Models; +using Govor.Core.Models.Messages; +using Microsoft.AspNetCore.Http; +using System.ComponentModel.DataAnnotations; + +namespace Govor.Contracts.Requests; + +public class MediaUploadRequest +{ + [Required] + 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; + [Required] + public MediaOwnerType OwnerType { get; set; } = MediaOwnerType.Message; +} \ No newline at end of file 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; +} 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/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.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/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/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/MediaReference.cs b/Govor.Contracts/Requests/SignalR/MediaReference.cs new file mode 100644 index 0000000..d2b9a1b --- /dev/null +++ b/Govor.Contracts/Requests/SignalR/MediaReference.cs @@ -0,0 +1,9 @@ +using Govor.Core.Models; + +namespace Govor.Contracts.Requests.SignalR; + +public record MediaReference +{ + public Guid MediaId { get; init; } + public string EncryptedKey { 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..d2dabaf --- /dev/null +++ b/Govor.Contracts/Requests/SignalR/MessageRequest.cs @@ -0,0 +1,15 @@ +using Govor.Core.Models.Messages; +using System.ComponentModel.DataAnnotations; + +namespace Govor.Contracts.Requests.SignalR; + +public record MessageRequest +{ + public Guid RecipientId { get; init; } + public RecipientType RecipientType { get; init; } + [Required] + [MaxLength(100_000, ErrorMessage = "EncryptedContent cannot exceed 100,000 characters.")] + 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/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/Requests/UploadKeysRequest.cs b/Govor.Contracts/Requests/UploadKeysRequest.cs new file mode 100644 index 0000000..f8d48ff --- /dev/null +++ b/Govor.Contracts/Requests/UploadKeysRequest.cs @@ -0,0 +1,9 @@ +namespace Govor.Contracts.Requests; + +public class UploadKeysRequest +{ + 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.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.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 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/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.Contracts/Responses/SignalR/HubResult.cs b/Govor.Contracts/Responses/SignalR/HubResult.cs new file mode 100644 index 0000000..0875275 --- /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 : int +{ + Success = 200, + Created = 201, + NoContent = 204, + BadRequest = 400, + Unauthorized = 401, + NotFound = 404, + Conflict = 409, + UnprocessableEntity = 422, + ServerError = 500, +} \ No newline at end of file diff --git a/Govor.Contracts/Responses/SignalR/MessageEditResponse.cs b/Govor.Contracts/Responses/SignalR/MessageEditResponse.cs new file mode 100644 index 0000000..f03c638 --- /dev/null +++ b/Govor.Contracts/Responses/SignalR/MessageEditResponse.cs @@ -0,0 +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.Contracts/Responses/SignalR/MessageRemovedResponse.cs b/Govor.Contracts/Responses/SignalR/MessageRemovedResponse.cs new file mode 100644 index 0000000..89471b4 --- /dev/null +++ b/Govor.Contracts/Responses/SignalR/MessageRemovedResponse.cs @@ -0,0 +1,11 @@ +using Govor.Core.Models.Messages; + +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 diff --git a/Govor.Contracts/Responses/SignalR/UserMessageResponse.cs b/Govor.Contracts/Responses/SignalR/UserMessageResponse.cs new file mode 100644 index 0000000..6632c60 --- /dev/null +++ b/Govor.Contracts/Responses/SignalR/UserMessageResponse.cs @@ -0,0 +1,17 @@ +using Govor.Core.Models; +using Govor.Core.Models.Messages; + +namespace Govor.Contracts.Responses.SignalR; + +public record UserMessageResponse +{ + 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; 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/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.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/ChatGroupValidator.cs b/Govor.Core/Infrastructure/Validators/ChatGroupValidator.cs new file mode 100644 index 0000000..c8e70ff --- /dev/null +++ b/Govor.Core/Infrastructure/Validators/ChatGroupValidator.cs @@ -0,0 +1,44 @@ +using Govor.Core.Models; + +namespace Govor.Core.Infrastructure.Validators; + +public class ChatGroupValidator : IObjectValidator +{ + public void Validate(ChatGroup chat) + { + 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 chat) + { + try + { + Validate(chat); + return true; + } + catch (Exception) + { + return false; + } + } +} \ No newline at end of file diff --git a/Govor.Core/Infrastructure/Validators/MediaAttachmentsValidator.cs b/Govor.Core/Infrastructure/Validators/MediaAttachmentsValidator.cs index fc20e82..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; @@ -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/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/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/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/ChatGroup.cs b/Govor.Core/Models/ChatGroup.cs index 1912474..406854e 100644 --- a/Govor.Core/Models/ChatGroup.cs +++ b/Govor.Core/Models/ChatGroup.cs @@ -1,11 +1,29 @@ +using Govor.Core.Models.Messages; + namespace Govor.Core.Models; public class ChatGroup { public Guid Id { get; set; } public string Name { get; set; } - public List InviteCode { get; set; } + public string Description { get; set; } + public Guid ImageId { 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(); + public List Messages { 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/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 new file mode 100644 index 0000000..3f135fd --- /dev/null +++ b/Govor.Core/Models/GroupInvitation.cs @@ -0,0 +1,17 @@ +using Govor.Core.Models.Users; + +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..509b19e 100644 --- a/Govor.Core/Models/GroupMembership.cs +++ b/Govor.Core/Models/GroupMembership.cs @@ -5,5 +5,7 @@ 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; } + public DateTime MemberSince { get; set; } } \ No newline at end of file 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/MediaAttachments.cs b/Govor.Core/Models/MediaAttachments.cs deleted file mode 100644 index bc5b42c..0000000 --- a/Govor.Core/Models/MediaAttachments.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace Govor.Core.Models; - -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 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; - } -} - -public enum MediaType -{ - Image, - Video, - Audio, - File, - Voice -} \ No newline at end of file diff --git a/Govor.Core/Models/MediaFile.cs b/Govor.Core/Models/MediaFile.cs new file mode 100644 index 0000000..940dbd5 --- /dev/null +++ b/Govor.Core/Models/MediaFile.cs @@ -0,0 +1,24 @@ +using Govor.Core.Models.Messages; + +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; } + public DateTime DateCreated { get; set; } + + public MediaOwnerType OwnerType { get; set; } = MediaOwnerType.Message; + public Guid? OwnerId { get; set; } +} + +public enum MediaOwnerType +{ + Message = 0, + Avatar = 1, + GroupAvatar = 2, + System = 3 // (Emoge, icons e.t.c) +} \ No newline at end of file diff --git a/Govor.Core/Models/Messages/MediaAttachments.cs b/Govor.Core/Models/Messages/MediaAttachments.cs new file mode 100644 index 0000000..99a5a6b --- /dev/null +++ b/Govor.Core/Models/Messages/MediaAttachments.cs @@ -0,0 +1,27 @@ +namespace Govor.Core.Models.Messages; + +public class MediaAttachments +{ + public Guid Id { get; set; } + public Guid MessageId { get; set; } + 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 && + MediaFileId == other.MediaFileId; + } +} + +public enum MediaType +{ + Image, + Video, + Audio, + File, + Voice +} \ No newline at end of file diff --git a/Govor.Core/Models/Message.cs b/Govor.Core/Models/Messages/Message.cs similarity index 93% rename from Govor.Core/Models/Message.cs rename to Govor.Core/Models/Messages/Message.cs index b1bac31..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 { @@ -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.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 82ef1e5..e043a92 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 @@ -5,5 +7,13 @@ 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) + { + PrivateChat other = obj as PrivateChat; + return Id == other.Id && + UserAId == other.UserAId && + UserBId == other.UserBId; + } } \ No newline at end of file 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/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/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 new file mode 100644 index 0000000..001c38d --- /dev/null +++ b/Govor.Core/Models/Users/PrivacyUserSettings.cs @@ -0,0 +1,47 @@ +namespace Govor.Core.Models.Users; + +public class PrivacyUserSettings +{ + public Guid UserId { get; set; } + + 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 +{ + None = 0, + OnlyFriends = 1, + Everyone = 2, +} + +public enum DeletingMessagesVia +{ + None = 0, + Hours = 1, + Days = 2, + Months = 3, + Years = 4 +} + +public enum PrivacyTargetArea +{ + CanSend = 0, + CanSeeTimeWas = 1, + CanSeeImage = 2, + CanSendImage = 3, +} + +public enum PrivacyRuleType +{ + Allow = 0, + Deny = 1 +} + + 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/Models/Users/UserSession.cs b/Govor.Core/Models/Users/UserSession.cs new file mode 100644 index 0000000..dcd448b --- /dev/null +++ b/Govor.Core/Models/Users/UserSession.cs @@ -0,0 +1,30 @@ +using Govor.Core.Models.Users.Crypto; + +namespace Govor.Core.Models.Users; + +public class UserSession +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid UserId { get; set; } + public string RefreshTokenHash { 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 DateTime? RevokedAt { get; set; } TODO: Clear old UserSessions + + public UserCryptoSession CryptoSession { get; set; } + + public override bool Equals(object? obj) + { + UserSession? userSession = obj as UserSession; + + return Id == userSession.Id && + UserId == userSession.UserId && + RefreshTokenHash == userSession.RefreshTokenHash && + DeviceInfo == userSession.DeviceInfo && + CreatedAt == userSession.CreatedAt && + ExpiresAt == userSession.ExpiresAt && + IsRevoked == userSession.IsRevoked; + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..d53b8ce --- /dev/null +++ b/Govor.Core/Repositories/Groups/IGroupMessagesReader.cs @@ -0,0 +1,8 @@ +using Govor.Core.Models.Messages; + +namespace Govor.Core.Repositories.Groups; + +public interface IGroupMessagesReader +{ + public Task> GetMessages(Guid chatId, Guid? startMessageId, int pageSize = 20, RecipientType type = RecipientType.User); +} \ 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..f6b763f --- /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 Exist(Guid groupId); + public bool Exist(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..26829d0 100644 --- a/Govor.Core/Repositories/Groups/IGroupsReader.cs +++ b/Govor.Core/Repositories/Groups/IGroupsReader.cs @@ -1,6 +1,13 @@ +using Govor.Core.Models; + namespace Govor.Core.Repositories.Groups; -public class IGroupsReader +public interface IGroupsReader { - + 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/IGroupsRepository.cs b/Govor.Core/Repositories/Groups/IGroupsRepository.cs new file mode 100644 index 0000000..9b2faa7 --- /dev/null +++ b/Govor.Core/Repositories/Groups/IGroupsRepository.cs @@ -0,0 +1,6 @@ +namespace Govor.Core.Repositories.Groups; + +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 new file mode 100644 index 0000000..4cc38f4 --- /dev/null +++ b/Govor.Core/Repositories/Groups/IGroupsWriter.cs @@ -0,0 +1,10 @@ +using Govor.Core.Models; + +namespace Govor.Core.Repositories.Groups; + +public interface IGroupsWriter +{ + Task AddAsync(ChatGroup group); + Task UpdateAsync(ChatGroup group); + Task RemoveAsync(Guid groupId); +} \ No newline at end of file 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/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..7a9829c --- /dev/null +++ b/Govor.Core/Repositories/PrivateChats/IPrivateChatsRepository.cs @@ -0,0 +1,8 @@ +using Govor.Core.Repositories.Groups; + +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.Core/Repositories/UserSessionsRepository/IUserSessionsExist.cs b/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsExist.cs new file mode 100644 index 0000000..1655ee5 --- /dev/null +++ b/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsExist.cs @@ -0,0 +1,10 @@ +using Govor.Core.Models.Users; + +namespace Govor.Core.Repositories.UserSessionsRepository; + +public interface IUserSessionsExist +{ + public bool Exist(Guid sessionId); + public bool Exist(string hashedToken); + 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..851569c --- /dev/null +++ b/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsReader.cs @@ -0,0 +1,14 @@ +using Govor.Core.Models.Users; + +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); + public Task GetByHashedRefreshTokenAsync(string hash); +} \ 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..4f7eeac --- /dev/null +++ b/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsWriter.cs @@ -0,0 +1,12 @@ +using Govor.Core.Models; +using Govor.Core.Models.Users; + +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.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/Govor.Data.Tests.csproj b/Govor.Data.Tests/Govor.Data.Tests.csproj new file mode 100644 index 0000000..8ad7ddc --- /dev/null +++ b/Govor.Data.Tests/Govor.Data.Tests.csproj @@ -0,0 +1,30 @@ + + + + net8.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..5e95ea6 100644 --- a/Govor.API.Tests/IntegrationTests/EF/Repositories/AdminsRepositoryTests.cs +++ b/Govor.Data.Tests/Repositories/AdminsRepositoryTests.cs @@ -1,13 +1,13 @@ 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; 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.Data.Tests/Repositories/GroupRepositoryTests.cs b/Govor.Data.Tests/Repositories/GroupRepositoryTests.cs new file mode 100644 index 0000000..35fb5d3 --- /dev/null +++ b/Govor.Data.Tests/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.Data.Tests.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/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..232b62c 100644 --- a/Govor.API.Tests/IntegrationTests/EF/Repositories/MediaAttachmentsTests.cs +++ b/Govor.Data.Tests/Repositories/MediaAttachmentsTests.cs @@ -1,12 +1,13 @@ 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; 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..7c4a144 100644 --- a/Govor.API.Tests/IntegrationTests/EF/Repositories/MessagesRepositoryTests.cs +++ b/Govor.Data.Tests/Repositories/MessagesRepositoryTests.cs @@ -1,12 +1,13 @@ 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; using Microsoft.EntityFrameworkCore; -namespace Govor.API.Tests.IntegrationTests.EF.Repositories; +namespace Govor.Data.Tests.Repositories; [TestFixture] public class MessagesRepositoryTests diff --git a/Govor.Data.Tests/Repositories/PrivateChatsRepositoryTests.cs b/Govor.Data.Tests/Repositories/PrivateChatsRepositoryTests.cs new file mode 100644 index 0000000..15d6b07 --- /dev/null +++ b/Govor.Data.Tests/Repositories/PrivateChatsRepositoryTests.cs @@ -0,0 +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.Data.Tests.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 repository = new PrivateChatsRepository(context, _validator); + + context.PrivateChats.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 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_ValidPrivateChat_When_AddAsync_Then_PrivateChatAdded() + { + // 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_InvalidPrivateChat_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_ExistPrivateChat_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_NotExistPrivateChat_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.Data.Tests/Repositories/UserSessionsRepositoryTests.cs b/Govor.Data.Tests/Repositories/UserSessionsRepositoryTests.cs new file mode 100644 index 0000000..234e21b --- /dev/null +++ b/Govor.Data.Tests/Repositories/UserSessionsRepositoryTests.cs @@ -0,0 +1,310 @@ +using AutoFixture; +using Govor.Core.Models; +using Govor.Core.Models.Users; +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_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().RefreshTokenHash; + + await using var context = new GovorDbContext(_options); + var repository = new UserSessionsRepository(context); + + context.UserSessions.AddRange(sessions); + await context.SaveChangesAsync(); + + // Act + var result = await repository.GetByHashedRefreshTokenAsync(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.GetByHashedRefreshTokenAsync(_fixture.Create())); + } + + [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.RefreshTokenHash); + + // 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.RefreshTokenHash); + // 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.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..8a37d46 100644 --- a/Govor.API.Tests/IntegrationTests/EF/Repositories/UsersRepositoryTests.cs +++ b/Govor.Data.Tests/Repositories/UsersRepositoryTests.cs @@ -1,12 +1,13 @@ 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; using Microsoft.EntityFrameworkCore; -namespace Govor.API.Tests.IntegrationTests.EF.Repositories; +namespace Govor.Data.Tests.Repositories; [TestFixture] public class UsersRepositoryTests 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/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..fb16926 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(false); + + // Optional: можно добавить навигацию к GroupInvitation + builder.HasOne() + .WithMany() + .HasForeignKey(e => e.InvitationId) + .OnDelete(DeleteBehavior.SetNull); } } \ No newline at end of file diff --git a/Govor.Data/Configurations/MediaAttachmentsConfiguration.cs b/Govor.Data/Configurations/MediaAttachmentsConfiguration.cs index d9c6bc4..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; @@ -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/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 39f8adb..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; @@ -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/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/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); + } +} 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/UserConfiguration.cs b/Govor.Data/Configurations/UserConfiguration.cs index 205442d..76edac4 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; @@ -13,5 +13,19 @@ public class UserConfiguration : IEntityTypeConfiguration builder.HasOne(u => u.Invite) .WithMany(i => i.Users) .HasForeignKey(u => u.InviteId); + + builder.Property(u => u.Username) + .IsRequired() + .HasMaxLength(50); + + builder.HasIndex(u => u.Username).IsUnique(); + + builder.Property(u => u.Description) + .HasMaxLength(500) + .IsRequired(false); + + builder.Property(u => u.PasswordHash) + .IsRequired() + .HasMaxLength(128); } } \ 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 new file mode 100644 index 0000000..bb5cf69 --- /dev/null +++ b/Govor.Data/Configurations/UserSessionConfiguration.cs @@ -0,0 +1,31 @@ +using Govor.Core.Models; +using Govor.Core.Models.Users; +using Govor.Core.Models.Users.Crypto; +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.RefreshTokenHash) + .IsRequired(); + + builder.Property(us => us.DeviceInfo) + .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/Govor.Data.csproj b/Govor.Data/Govor.Data.csproj index 29dc018..036ffa3 100644 --- a/Govor.Data/Govor.Data.csproj +++ b/Govor.Data/Govor.Data.csproj @@ -1,18 +1,19 @@  - net9.0 + net8.0 enable enable - - + + + - + diff --git a/Govor.Data/GovorDbContext.cs b/Govor.Data/GovorDbContext.cs index dea43e1..1593fe2 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.Core.Models.Users.Crypto; using Govor.Data.Configurations; using Microsoft.EntityFrameworkCore; @@ -8,6 +10,10 @@ 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 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; } @@ -18,13 +24,16 @@ 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 GroupInvitations { get; set; } public virtual DbSet GroupMemberships { get; set; } public virtual DbSet GroupAdmins { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.ApplyConfiguration(new UserSessionConfiguration()); modelBuilder.ApplyConfiguration(new FriendshipConfiguration()); modelBuilder.ApplyConfiguration(new UserConfiguration()); modelBuilder.ApplyConfiguration(new InvitationConfiguration()); @@ -33,7 +42,15 @@ public class GovorDbContext(DbContextOptions options) : DbContex modelBuilder.ApplyConfiguration(new MessageReactionConfiguration()); 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()); + + 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/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.Designer.cs b/Govor.Data/Migrations/20250627101322_friendships.Designer.cs deleted file mode 100644 index e3350fc..0000000 --- a/Govor.Data/Migrations/20250627101322_friendships.Designer.cs +++ /dev/null @@ -1,467 +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("20250627101322_friendships")] - partial class friendships - { - /// - 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.Friendship", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("AddresseeId") - .HasColumnType("uuid"); - - b.Property("RequesterId") - .HasColumnType("uuid"); - - b.Property("Status") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.HasIndex("AddresseeId"); - - b.HasIndex("RequesterId"); - - b.ToTable("Friendships"); - }); - - 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("PrivateChatId") - .HasColumnType("uuid"); - - 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("PrivateChatId"); - - 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.PrivateChat", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("UserAId") - .HasColumnType("uuid"); - - b.Property("UserBId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.ToTable("PrivateChats"); - }); - - 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.Friendship", b => - { - b.HasOne("Govor.Core.Models.User", "Addressee") - .WithMany("ReceivedFriendRequests") - .HasForeignKey("AddresseeId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("Govor.Core.Models.User", "Requester") - .WithMany("SentFriendRequests") - .HasForeignKey("RequesterId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Addressee"); - - b.Navigation("Requester"); - }); - - 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.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 => - { - 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"); - }); - - modelBuilder.Entity("Govor.Core.Models.PrivateChat", b => - { - b.Navigation("Messages"); - }); - - modelBuilder.Entity("Govor.Core.Models.User", b => - { - b.Navigation("ReceivedFriendRequests"); - - b.Navigation("SentFriendRequests"); - }); -#pragma warning restore 612, 618 - } - } -} 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/20251019125424_InitialCreate.Designer.cs b/Govor.Data/Migrations/20251019125424_InitialCreate.Designer.cs new file mode 100644 index 0000000..fa346ae --- /dev/null +++ b/Govor.Data/Migrations/20251019125424_InitialCreate.Designer.cs @@ -0,0 +1,763 @@ +// +using System; +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("20251019125424_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Govor.Core.Models.ChatGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ImageId") + .HasColumnType("uuid"); + + b.Property("IsChannel") + .HasColumnType("boolean"); + + b.Property("IsPrivate") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.ToTable("ChatGroups"); + }); + + modelBuilder.Entity("Govor.Core.Models.Friendship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddresseeId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AddresseeId"); + + b.HasIndex("RequesterId"); + + b.ToTable("Friendships"); + }); + + 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.HasIndex("GroupId"); + + b.ToTable("GroupAdmins"); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("InvitationCode") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("MaxParticipants") + .HasColumnType("integer"); + + b.Property("UserMakerId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("UserMakerId"); + + b.ToTable("GroupInvitations"); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupMembership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("InvitationId") + .HasColumnType("uuid"); + + b.Property("IsBanned") + .HasColumnType("boolean"); + + b.Property("MemberSince") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("InvitationId"); + + 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.MediaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("MediaType") + .IsRequired() + .HasColumnType("text"); + + b.Property("MineType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UploaderId") + .HasColumnType("uuid"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("MediaFiles"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.MediaAttachments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MediaFileId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + 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("uuid"); + + b.Property("ChatGroupId") + .HasColumnType("uuid"); + + b.Property("EditedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsEdited") + .HasColumnType("boolean"); + + b.Property("PrivateChatId") + .HasColumnType("uuid"); + + 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("ChatGroupId"); + + b.HasIndex("PrivateChatId"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.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.Messages.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.PrivateChat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("UserAId") + .HasColumnType("uuid"); + + b.Property("UserBId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("PrivateChats"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.Admin", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("UserId"); + + b.ToTable("Admins"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.Crypto.OneTimePreKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsUsed") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("UploadedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserCryptoSessionId") + .HasColumnType("uuid"); + + 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("uuid"); + + b.Property("PublicSignedPreKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("SignedPreKeySignature") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("UserCryptoSessionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserCryptoSessionId") + .IsUnique(); + + b.ToTable("SignedPreKeys"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.Crypto.UserCryptoSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("PublicIdentityKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("UserSessionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserSessionId") + .IsUnique(); + + b.ToTable("UserCryptoSessions"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.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.Users.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceInfo") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("RefreshToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + 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/20251019125424_InitialCreate.cs b/Govor.Data/Migrations/20251019125424_InitialCreate.cs new file mode 100644 index 0000000..d77b539 --- /dev/null +++ b/Govor.Data/Migrations/20251019125424_InitialCreate.cs @@ -0,0 +1,570 @@ +using System; +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: "character varying(100)", maxLength: 100, nullable: false), + Description = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + ImageId = table.Column(type: "uuid", nullable: false), + IsChannel = table.Column(type: "boolean", nullable: false), + IsPrivate = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChatGroups", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Invitations", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + IsAdmin = table.Column(type: "boolean", nullable: false), + IsActive = 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: "MediaFiles", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UploaderId = table.Column(type: "uuid", nullable: false), + Url = table.Column(type: "text", nullable: false), + MediaType = table.Column(type: "text", nullable: false), + MineType = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + DateCreated = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MediaFiles", x => x.Id); + }); + + 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.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); + table.ForeignKey( + name: "FK_GroupAdmins_ChatGroups_GroupId", + column: x => x.GroupId, + principalTable: "ChatGroups", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + 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: "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), + ChatGroupId = table.Column(type: "uuid", nullable: true), + PrivateChatId = table.Column(type: "uuid", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Messages", x => x.Id); + table.ForeignKey( + name: "FK_Messages_ChatGroups_ChatGroupId", + column: x => x.ChatGroupId, + principalTable: "ChatGroups", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_Messages_PrivateChats_PrivateChatId", + column: x => x.PrivateChatId, + principalTable: "PrivateChats", + principalColumn: "Id"); + }); + + 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: "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: "GroupInvitations", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + GroupId = table.Column(type: "uuid", nullable: false), + UserMakerId = table.Column(type: "uuid", nullable: false), + InvitationCode = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Description = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + EndDate = table.Column(type: "timestamp with time zone", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + MaxParticipants = table.Column(type: "integer", 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); + }); + + migrationBuilder.CreateTable( + name: "UserSessions", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + RefreshToken = table.Column(type: "text", nullable: false), + DeviceInfo = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + IsRevoked = table.Column(type: "boolean", 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); + }); + + migrationBuilder.CreateTable( + name: "MediaAttachments", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + MessageId = table.Column(type: "uuid", nullable: false), + MediaFileId = table.Column(type: "uuid", nullable: false) + }, + 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, + principalTable: "Messages", + 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.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: "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), + InvitationId = table.Column(type: "uuid", nullable: true), + IsBanned = table.Column(type: "boolean", nullable: false), + MemberSince = table.Column(type: "timestamp with time zone", 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); + }); + + migrationBuilder.CreateTable( + name: "UserCryptoSessions", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserSessionId = table.Column(type: "uuid", nullable: false), + PublicIdentityKey = table.Column(type: "bytea", 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); + }); + + migrationBuilder.CreateTable( + name: "OneTimePreKeys", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserCryptoSessionId = table.Column(type: "uuid", nullable: false), + PublicKey = table.Column(type: "bytea", nullable: false), + IsUsed = table.Column(type: "boolean", nullable: false, defaultValue: false), + UploadedAt = table.Column(type: "timestamp with time zone", 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); + }); + + migrationBuilder.CreateTable( + name: "SignedPreKeys", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserCryptoSessionId = table.Column(type: "uuid", nullable: false), + PublicSignedPreKey = table.Column(type: "bytea", nullable: false), + SignedPreKeySignature = table.Column(type: "bytea", 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); + }); + + migrationBuilder.CreateIndex( + name: "IX_Friendships_AddresseeId", + table: "Friendships", + column: "AddresseeId"); + + migrationBuilder.CreateIndex( + name: "IX_Friendships_RequesterId", + 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", + 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_ChatGroupId", + table: "Messages", + column: "ChatGroupId"); + + migrationBuilder.CreateIndex( + name: "IX_Messages_PrivateChatId", + table: "Messages", + column: "PrivateChatId"); + + migrationBuilder.CreateIndex( + name: "IX_MessageViews_MessageId_UserId", + table: "MessageViews", + columns: new[] { "MessageId", "UserId" }, + unique: true); + + 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); + + migrationBuilder.CreateIndex( + name: "IX_Users_InviteId", + table: "Users", + column: "InviteId"); + + migrationBuilder.CreateIndex( + name: "IX_UserSessions_UserId", + table: "UserSessions", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Admins"); + + 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: "OneTimePreKeys"); + + migrationBuilder.DropTable( + name: "SignedPreKeys"); + + migrationBuilder.DropTable( + name: "GroupInvitations"); + + migrationBuilder.DropTable( + name: "MediaFiles"); + + migrationBuilder.DropTable( + name: "Messages"); + + migrationBuilder.DropTable( + name: "UserCryptoSessions"); + + migrationBuilder.DropTable( + name: "ChatGroups"); + + migrationBuilder.DropTable( + name: "PrivateChats"); + + migrationBuilder.DropTable( + name: "UserSessions"); + + migrationBuilder.DropTable( + name: "Users"); + + migrationBuilder.DropTable( + name: "Invitations"); + } + } +} diff --git a/Govor.Data/Migrations/20251103060801_MediaOwnerTypeAdded.Designer.cs b/Govor.Data/Migrations/20251103060801_MediaOwnerTypeAdded.Designer.cs new file mode 100644 index 0000000..93a2188 --- /dev/null +++ b/Govor.Data/Migrations/20251103060801_MediaOwnerTypeAdded.Designer.cs @@ -0,0 +1,769 @@ +// +using System; +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("20251103060801_MediaOwnerTypeAdded")] + partial class MediaOwnerTypeAdded + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Govor.Core.Models.ChatGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ImageId") + .HasColumnType("uuid"); + + b.Property("IsChannel") + .HasColumnType("boolean"); + + b.Property("IsPrivate") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.ToTable("ChatGroups"); + }); + + modelBuilder.Entity("Govor.Core.Models.Friendship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddresseeId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AddresseeId"); + + b.HasIndex("RequesterId"); + + b.ToTable("Friendships"); + }); + + 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.HasIndex("GroupId"); + + b.ToTable("GroupAdmins"); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("InvitationCode") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("MaxParticipants") + .HasColumnType("integer"); + + b.Property("UserMakerId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("UserMakerId"); + + b.ToTable("GroupInvitations"); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupMembership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("InvitationId") + .HasColumnType("uuid"); + + b.Property("IsBanned") + .HasColumnType("boolean"); + + b.Property("MemberSince") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("InvitationId"); + + 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.MediaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("MediaType") + .IsRequired() + .HasColumnType("text"); + + b.Property("MineType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("OwnerId") + .HasColumnType("uuid"); + + b.Property("OwnerType") + .HasColumnType("integer"); + + b.Property("UploaderId") + .HasColumnType("uuid"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("MediaFiles"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.MediaAttachments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MediaFileId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + 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("uuid"); + + b.Property("ChatGroupId") + .HasColumnType("uuid"); + + b.Property("EditedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsEdited") + .HasColumnType("boolean"); + + b.Property("PrivateChatId") + .HasColumnType("uuid"); + + 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("ChatGroupId"); + + b.HasIndex("PrivateChatId"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.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.Messages.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.PrivateChat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("UserAId") + .HasColumnType("uuid"); + + b.Property("UserBId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("PrivateChats"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.Admin", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("UserId"); + + b.ToTable("Admins"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.Crypto.OneTimePreKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsUsed") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("UploadedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserCryptoSessionId") + .HasColumnType("uuid"); + + 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("uuid"); + + b.Property("PublicSignedPreKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("SignedPreKeySignature") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("UserCryptoSessionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserCryptoSessionId") + .IsUnique(); + + b.ToTable("SignedPreKeys"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.Crypto.UserCryptoSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("PublicIdentityKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("UserSessionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserSessionId") + .IsUnique(); + + b.ToTable("UserCryptoSessions"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.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.Users.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceInfo") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("RefreshToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + 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/20251103060801_MediaOwnerTypeAdded.cs b/Govor.Data/Migrations/20251103060801_MediaOwnerTypeAdded.cs new file mode 100644 index 0000000..c9bce75 --- /dev/null +++ b/Govor.Data/Migrations/20251103060801_MediaOwnerTypeAdded.cs @@ -0,0 +1,40 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Govor.Data.Migrations +{ + /// + public partial class MediaOwnerTypeAdded : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "OwnerId", + table: "MediaFiles", + type: "uuid", + nullable: true); + + migrationBuilder.AddColumn( + name: "OwnerType", + table: "MediaFiles", + type: "integer", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "OwnerId", + table: "MediaFiles"); + + migrationBuilder.DropColumn( + name: "OwnerType", + table: "MediaFiles"); + } + } +} diff --git a/Govor.Data/Migrations/20251214065852_update_sessions_model.Designer.cs b/Govor.Data/Migrations/20251214065852_update_sessions_model.Designer.cs new file mode 100644 index 0000000..7a52bfb --- /dev/null +++ b/Govor.Data/Migrations/20251214065852_update_sessions_model.Designer.cs @@ -0,0 +1,774 @@ +// +using System; +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("20251214065852_update_sessions_model")] + partial class update_sessions_model + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Govor.Core.Models.ChatGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ImageId") + .HasColumnType("uuid"); + + b.Property("IsChannel") + .HasColumnType("boolean"); + + b.Property("IsPrivate") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.ToTable("ChatGroups"); + }); + + modelBuilder.Entity("Govor.Core.Models.Friendship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddresseeId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AddresseeId"); + + b.HasIndex("RequesterId"); + + b.ToTable("Friendships"); + }); + + 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.HasIndex("GroupId"); + + b.ToTable("GroupAdmins"); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("InvitationCode") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("MaxParticipants") + .HasColumnType("integer"); + + b.Property("UserMakerId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("UserMakerId"); + + b.ToTable("GroupInvitations"); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupMembership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("InvitationId") + .HasColumnType("uuid"); + + b.Property("IsBanned") + .HasColumnType("boolean"); + + b.Property("MemberSince") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("InvitationId"); + + 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.MediaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("MediaType") + .IsRequired() + .HasColumnType("text"); + + b.Property("MineType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("OwnerId") + .HasColumnType("uuid"); + + b.Property("OwnerType") + .HasColumnType("integer"); + + b.Property("UploaderId") + .HasColumnType("uuid"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("MediaFiles"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.MediaAttachments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MediaFileId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + 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("uuid"); + + b.Property("ChatGroupId") + .HasColumnType("uuid"); + + b.Property("EditedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsEdited") + .HasColumnType("boolean"); + + b.Property("PrivateChatId") + .HasColumnType("uuid"); + + 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("ChatGroupId"); + + b.HasIndex("PrivateChatId"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.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.Messages.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.PrivateChat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("UserAId") + .HasColumnType("uuid"); + + b.Property("UserBId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("PrivateChats"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.Admin", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("UserId"); + + b.ToTable("Admins"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.Crypto.OneTimePreKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsUsed") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("UploadedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserCryptoSessionId") + .HasColumnType("uuid"); + + 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("uuid"); + + b.Property("PublicSignedPreKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("SignedPreKeySignature") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("UserCryptoSessionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserCryptoSessionId") + .IsUnique(); + + b.ToTable("SignedPreKeys"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.Crypto.UserCryptoSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("PublicIdentityKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("UserSessionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserSessionId") + .IsUnique(); + + b.ToTable("UserCryptoSessions"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedOn") + .HasColumnType("date"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IconId") + .HasColumnType("uuid"); + + b.Property("InviteId") + .HasColumnType("uuid"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("WasOnline") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("InviteId"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceInfo") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("RefreshTokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + 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/20251214065852_update_sessions_model.cs b/Govor.Data/Migrations/20251214065852_update_sessions_model.cs new file mode 100644 index 0000000..9e7a7f9 --- /dev/null +++ b/Govor.Data/Migrations/20251214065852_update_sessions_model.cs @@ -0,0 +1,94 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Govor.Data.Migrations +{ + /// + public partial class update_sessions_model : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "RefreshToken", + table: "UserSessions", + newName: "RefreshTokenHash"); + + migrationBuilder.AlterColumn( + name: "Username", + table: "Users", + type: "character varying(50)", + maxLength: 50, + nullable: false, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AlterColumn( + name: "PasswordHash", + table: "Users", + type: "character varying(128)", + maxLength: 128, + nullable: false, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AlterColumn( + name: "Description", + table: "Users", + type: "character varying(500)", + maxLength: 500, + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.CreateIndex( + name: "IX_Users_Username", + table: "Users", + column: "Username", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Users_Username", + table: "Users"); + + migrationBuilder.RenameColumn( + name: "RefreshTokenHash", + table: "UserSessions", + newName: "RefreshToken"); + + migrationBuilder.AlterColumn( + name: "Username", + table: "Users", + type: "text", + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(50)", + oldMaxLength: 50); + + migrationBuilder.AlterColumn( + name: "PasswordHash", + table: "Users", + type: "text", + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(128)", + oldMaxLength: 128); + + migrationBuilder.AlterColumn( + name: "Description", + table: "Users", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "character varying(500)", + oldMaxLength: 500, + oldNullable: true); + } + } +} diff --git a/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs b/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs index 703662e..6217647 100644 --- a/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs +++ b/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs @@ -1,6 +1,5 @@ // using System; -using System.Collections.Generic; using Govor.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -18,34 +17,24 @@ namespace Govor.Data.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "9.0.6") + .HasAnnotation("ProductVersion", "8.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") + b.Property("Description") .IsRequired() - .HasColumnType("uuid[]"); + .HasMaxLength(500) + .HasColumnType("character varying(500)"); - b.PrimitiveCollection>("InviteCode") - .IsRequired() - .HasColumnType("text[]"); + b.Property("ImageId") + .HasColumnType("uuid"); b.Property("IsChannel") .HasColumnType("boolean"); @@ -55,7 +44,8 @@ namespace Govor.Data.Migrations b.Property("Name") .IsRequired() - .HasColumnType("text"); + .HasMaxLength(100) + .HasColumnType("character varying(100)"); b.HasKey("Id"); @@ -100,9 +90,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("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("InvitationCode") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("MaxParticipants") + .HasColumnType("integer"); + + b.Property("UserMakerId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("UserMakerId"); + + b.ToTable("GroupInvitations"); + }); + modelBuilder.Entity("Govor.Core.Models.GroupMembership", b => { b.Property("Id") @@ -112,14 +144,24 @@ namespace Govor.Data.Migrations b.Property("GroupId") .HasColumnType("uuid"); + b.Property("InvitationId") + .HasColumnType("uuid"); + b.Property("IsBanned") .HasColumnType("boolean"); + b.Property("MemberSince") + .HasColumnType("timestamp with time zone"); + b.Property("UserId") .HasColumnType("uuid"); b.HasKey("Id"); + b.HasIndex("GroupId"); + + b.HasIndex("InvitationId"); + b.ToTable("GroupMemberships"); }); @@ -157,44 +199,72 @@ namespace Govor.Data.Migrations b.ToTable("Invitations"); }); - modelBuilder.Entity("Govor.Core.Models.MediaAttachments", b => + modelBuilder.Entity("Govor.Core.Models.MediaFile", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("uuid"); - b.Property("EncryptedKey") - .HasMaxLength(512) - .HasColumnType("character varying(512)"); + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); - b.Property("FilePath") + b.Property("MediaType") .IsRequired() .HasColumnType("text"); - b.Property("MessageId") + b.Property("MineType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("OwnerId") .HasColumnType("uuid"); - b.Property("MimeType") - .IsRequired() - .HasColumnType("text"); + b.Property("OwnerType") + .HasColumnType("integer"); - b.Property("Type") + b.Property("UploaderId") + .HasColumnType("uuid"); + + b.Property("Url") .IsRequired() .HasColumnType("text"); b.HasKey("Id"); + b.ToTable("MediaFiles"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.MediaAttachments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MediaFileId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MediaFileId"); + b.HasIndex("MessageId"); b.ToTable("MediaAttachments"); }); - modelBuilder.Entity("Govor.Core.Models.Message", b => + modelBuilder.Entity("Govor.Core.Models.Messages.Message", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("uuid"); + b.Property("ChatGroupId") + .HasColumnType("uuid"); + b.Property("EditedAt") .HasColumnType("timestamp with time zone"); @@ -225,14 +295,14 @@ namespace Govor.Data.Migrations b.HasKey("Id"); - b.HasIndex("PrivateChatId"); + b.HasIndex("ChatGroupId"); - b.HasIndex("ReplyToMessageId"); + 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() @@ -262,7 +332,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() @@ -302,7 +372,93 @@ 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("uuid"); + + b.HasKey("UserId"); + + b.ToTable("Admins"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.Crypto.OneTimePreKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsUsed") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("UploadedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserCryptoSessionId") + .HasColumnType("uuid"); + + 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("uuid"); + + b.Property("PublicSignedPreKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("SignedPreKeySignature") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("UserCryptoSessionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserCryptoSessionId") + .IsUnique(); + + b.ToTable("SignedPreKeys"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.Crypto.UserCryptoSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("PublicIdentityKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("UserSessionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserSessionId") + .IsUnique(); + + b.ToTable("UserCryptoSessions"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.User", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -312,8 +468,8 @@ namespace Govor.Data.Migrations .HasColumnType("date"); b.Property("Description") - .IsRequired() - .HasColumnType("text"); + .HasMaxLength(500) + .HasColumnType("character varying(500)"); b.Property("IconId") .HasColumnType("uuid"); @@ -323,11 +479,13 @@ namespace Govor.Data.Migrations b.Property("PasswordHash") .IsRequired() - .HasColumnType("text"); + .HasMaxLength(128) + .HasColumnType("character varying(128)"); b.Property("Username") .IsRequired() - .HasColumnType("text"); + .HasMaxLength(50) + .HasColumnType("character varying(50)"); b.Property("WasOnline") .HasColumnType("timestamp with time zone"); @@ -336,29 +494,55 @@ namespace Govor.Data.Migrations b.HasIndex("InviteId"); + b.HasIndex("Username") + .IsUnique(); + b.ToTable("Users"); }); - modelBuilder.Entity("Govor.Core.Models.Admin", b => + modelBuilder.Entity("Govor.Core.Models.Users.UserSession", b => { - b.HasOne("Govor.Core.Models.User", "User") - .WithOne() - .HasForeignKey("Govor.Core.Models.Admin", "UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); - b.Navigation("User"); + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceInfo") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("RefreshTokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserSessions"); }); 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) @@ -369,40 +553,85 @@ namespace Govor.Data.Migrations b.Navigation("Requester"); }); - modelBuilder.Entity("Govor.Core.Models.MediaAttachments", b => + modelBuilder.Entity("Govor.Core.Models.GroupAdmins", b => { - b.HasOne("Govor.Core.Models.Message", "Message") + 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.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"); - - b.HasOne("Govor.Core.Models.Message", "ReplyToMessage") - .WithMany() - .HasForeignKey("ReplyToMessageId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("ReplyToMessage"); }); - 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) @@ -413,16 +642,60 @@ 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.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") @@ -433,12 +706,32 @@ 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"); + + b.Navigation("InviteCodes"); + + b.Navigation("Members"); + + b.Navigation("Messages"); + }); + modelBuilder.Entity("Govor.Core.Models.Invitation", b => { b.Navigation("Users"); }); - modelBuilder.Entity("Govor.Core.Models.Message", b => + modelBuilder.Entity("Govor.Core.Models.Messages.Message", b => { b.Navigation("MediaAttachments"); @@ -452,12 +745,26 @@ namespace Govor.Data.Migrations b.Navigation("Messages"); }); - modelBuilder.Entity("Govor.Core.Models.User", b => + 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/Repositories/AdminsRepository.cs b/Govor.Data/Repositories/AdminsRepository.cs index 0e5783b..8e569bc 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; @@ -16,6 +17,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 +26,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 +63,9 @@ 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}"); + + await _context.SaveChangesAsync(); } 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..87e98f8 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; @@ -24,6 +25,7 @@ public class FriendshipsRepository : IFriendshipsRepository .AsNoTracking() .Include(x => x.Requester) .Include(x => x.Addressee) + .AsSplitQuery() .ToListOrThrowIfEmpty(new NotFoundException("Database is empty")); } @@ -33,6 +35,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,7 +46,9 @@ public class FriendshipsRepository : IFriendshipsRepository .AsNoTracking() .Include(x => x.Requester) .Include(x => x.Addressee) - .Where(x => x.RequesterId == userId) + .AsSplitQuery() + .Where(f => + (f.RequesterId == userId || f.AddresseeId == userId)) .ToListOrThrowIfEmpty(new NotFoundByKeyException(userId, "Friendship with given user id was not found")); } @@ -81,11 +86,9 @@ 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}"); + + await _context.SaveChangesAsync(); } catch (Exception ex) { @@ -114,6 +117,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/GroupRepository.cs b/Govor.Data/Repositories/GroupRepository.cs index 18b2dbb..f3a9b3d 100644 --- a/Govor.Data/Repositories/GroupRepository.cs +++ b/Govor.Data/Repositories/GroupRepository.cs @@ -1,6 +1,166 @@ +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; + namespace Govor.Data.Repositories; -public class GroupRepository +public class GroupRepository : IGroupsRepository { + private GovorDbContext _context; + private IObjectValidator _validator; -} \ No newline at end of file + public GroupRepository(GovorDbContext context, IObjectValidator validator) + { + _context = context; + _validator = validator; + } + + public async Task> GetAllAsync() + { + 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 async Task GetByIdAsync(Guid id) + { + 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 async Task> SearchByNameAsync(string name) + { + 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 async Task> GetByUserIdAsync(Guid userId) + { + 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 async Task AddAsync(ChatGroup group) + { + 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}"); + + await _context.SaveChangesAsync(); + } + 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 Exist(Guid groupId) + { + return _context.ChatGroups.Any(g => g.Id == groupId); + } + + public bool Exist(ChatGroup chatGroup) + { + 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 async Task IsUserMemberOfGroupAsync(Guid userId, Guid groupId) + { + 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/InvitesRepository.cs b/Govor.Data/Repositories/InvitesRepository.cs index 5ae9da4..ba9050d 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; @@ -23,6 +24,7 @@ public class InvitesRepository : IInvitesRepository return await _context.Invitations .AsNoTracking() .Include(i => i.Users) + .AsSplitQuery() .ToListOrThrowIfEmpty(new NotFoundException("Database is empty")); } @@ -31,6 +33,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 +43,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 +53,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 +99,9 @@ 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}"); + + await _context.SaveChangesAsync(); } catch (Exception ex) { diff --git a/Govor.Data/Repositories/MediaAttachmentsRepository.cs b/Govor.Data/Repositories/MediaAttachmentsRepository.cs index 83ded41..bb7588d 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; @@ -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"); } @@ -74,17 +77,13 @@ 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) - 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}"); + + await _context.SaveChangesAsync(); } catch (Exception ex) { @@ -122,11 +121,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 3da8cd6..4932297 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; @@ -21,7 +22,9 @@ 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")); } @@ -29,7 +32,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"); } @@ -38,7 +41,9 @@ 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")); } @@ -47,7 +52,9 @@ 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")); } @@ -56,7 +63,9 @@ 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 && m.RecipientType == recipientType) @@ -67,7 +76,9 @@ 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")); } @@ -112,15 +123,13 @@ 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) - 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}"); + + await _context.SaveChangesAsync(); } catch (Exception ex) { @@ -159,7 +168,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 ); } diff --git a/Govor.Data/Repositories/PrivateChatsRepository.cs b/Govor.Data/Repositories/PrivateChatsRepository.cs new file mode 100644 index 0000000..38a3f58 --- /dev/null +++ b/Govor.Data/Repositories/PrivateChatsRepository.cs @@ -0,0 +1,118 @@ +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; + +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}"); + + await _context.SaveChangesAsync(); + } + 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 diff --git a/Govor.Data/Repositories/UserSessionsRepository.cs b/Govor.Data/Repositories/UserSessionsRepository.cs new file mode 100644 index 0000000..1355425 --- /dev/null +++ b/Govor.Data/Repositories/UserSessionsRepository.cs @@ -0,0 +1,172 @@ +using Govor.Core.Infrastructure.Extensions; +using Govor.Core.Models.Users; +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 GetByHashedRefreshTokenAsync(string hash) + { + return await _context.UserSessions + .AsNoTracking() + .FirstOrDefaultAsync(session => session.RefreshTokenHash == hash) + ?? throw new NotFoundByKeyException(hash, "Session with given refresh token 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.RefreshTokenHash, userSession.RefreshTokenHash) + .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}"); + + await _context.SaveChangesAsync(); + } + 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 hashedToken) + { + return _context.UserSessions.Any(session => session.RefreshTokenHash == hashedToken); + } + + public bool Exist(UserSession userSession) + { + return _context.UserSessions.Any(session => session.Id == userSession.Id && + session.RefreshTokenHash == userSession.RefreshTokenHash && + 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/Govor.Data/Repositories/UsersRepository.cs b/Govor.Data/Repositories/UsersRepository.cs index d665b8d..8cbe546 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; @@ -25,6 +24,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 +38,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 +54,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 +68,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 +80,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 +103,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 +118,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 +151,19 @@ 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}"); + + await _context.SaveChangesAsync(); } catch (Exception ex) { @@ -218,5 +223,4 @@ public class UsersRepository : IUsersRepository { return _context.Users.AnyAsync(u => u.Username == username); } - } \ No newline at end of file diff --git a/Govor.sln b/Govor.sln index 2a9769a..271c981 100644 --- a/Govor.sln +++ b/Govor.sln @@ -8,16 +8,25 @@ 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 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 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 +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 @@ -52,6 +61,18 @@ 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 + {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 + {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} @@ -61,5 +82,8 @@ 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} + {CF6B23EC-932A-4998-BA95-C94CAB7B092C} = {4ED5259A-6FB4-4D89-8E6B-4778DC68F7D4} + {B1E79EB6-DBD3-4E82-AB6D-DCFCE6533965} = {4ED5259A-6FB4-4D89-8E6B-4778DC68F7D4} EndGlobalSection EndGlobal diff --git a/README.md b/README.md new file mode 100644 index 0000000..5f1acb5 --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# School Messenger (ASP.NET Core WebAPI) + +A simple real-time messenger built with ASP.NET Core Web API + Entity Framework Core. +This is my school project that I'm working on in my free time to level up my backend development skills. + +## Current Features +- Registration and authentication (JWT) +- Creating private chats and real-time messaging (via SignalR) +- Sending text messages and images +- User profiles with avatars +- Basic admin panel (still under development) + +(The list will be updated as new features are added) + +## Tech Stack +- Backend: ASP.NET Core 8 Web API +- Database: PostgreSQL or Microsoft SQL Server (configurable via appsettings) +- Authentication: JWT + Refresh Tokens +- Real-time communication: SignalR +- Frontend (separate project for now): .NET MAUI (cross-platform desktop/mobile client) + +dock: https://nas-3.gitbook.io/govor-api diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..482e6b1 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,48 @@ +--- +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 new file mode 100644 index 0000000..1403c18 --- /dev/null +++ b/docs/SUMMARY.md @@ -0,0 +1,29 @@ +# Table of contents + +* [The invitation code RU](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/code-docs/hubresult-less-than-t-greater-than.md b/docs/code-docs/hubresult-less-than-t-greater-than.md new file mode 100644 index 0000000..8e1bf6d --- /dev/null +++ b/docs/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/docs/endpoints/authentication/README.md b/docs/endpoints/authentication/README.md new file mode 100644 index 0000000..c891b49 --- /dev/null +++ b/docs/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/docs/endpoints/authentication/authcontroller.md b/docs/endpoints/authentication/authcontroller.md new file mode 100644 index 0000000..3cd6d03 --- /dev/null +++ b/docs/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/docs/endpoints/authentication/refreshcontroller.md b/docs/endpoints/authentication/refreshcontroller.md new file mode 100644 index 0000000..403b6b5 --- /dev/null +++ b/docs/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/docs/endpoints/chatloadcontroller.md b/docs/endpoints/chatloadcontroller.md new file mode 100644 index 0000000..5917f14 --- /dev/null +++ b/docs/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/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/docs/endpoints/mediacontroller.md b/docs/endpoints/mediacontroller.md new file mode 100644 index 0000000..a9b47f5 --- /dev/null +++ b/docs/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/docs/endpoints/onlinepingingcontroller-ne-rabotaet.md b/docs/endpoints/onlinepingingcontroller-ne-rabotaet.md new file mode 100644 index 0000000..adb67f5 --- /dev/null +++ b/docs/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/docs/endpoints/sessioncontroller.md b/docs/endpoints/sessioncontroller.md new file mode 100644 index 0000000..abdc3b8 --- /dev/null +++ b/docs/endpoints/sessioncontroller.md @@ -0,0 +1,157 @@ +--- +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." + ``` + +#### 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. +* **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. diff --git a/docs/friendshub/README.md b/docs/friendshub/README.md new file mode 100644 index 0000000..c52a7a1 --- /dev/null +++ b/docs/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/docs/friendshub/friendshub-client-java.md b/docs/friendshub/friendshub-client-java.md new file mode 100644 index 0000000..e9b616b --- /dev/null +++ b/docs/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/docs/signalr/chathub.md b/docs/signalr/chathub.md new file mode 100644 index 0000000..368938f --- /dev/null +++ b/docs/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/docs/signalr/presencehub.md b/docs/signalr/presencehub.md new file mode 100644 index 0000000..eb29710 --- /dev/null +++ b/docs/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. 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 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)