was added firebase nitifying

This commit is contained in:
Artemy
2026-03-01 16:48:06 +07:00
parent 76d7280c71
commit eab0d746b8
60 changed files with 2335 additions and 1149 deletions
@@ -0,0 +1,143 @@
using Govor.API.Hubs.Infrastructure;
namespace Govor.API.Tests.IntegrationTests.Hubs.Infrastructure;
[TestFixture]
public class ConnectionStoreTests
{
private ConnectionStore _store;
[SetUp]
public void Setup()
{
_store = new ConnectionStore();
}
[Test]
public void AddConnection_AddsConnection()
{
// Arrange
var userId = Guid.NewGuid();
var connectionId = "conn1";
// Act
_store.AddConnection(userId, connectionId);
var connections = _store.GetConnections(userId);
// Assert
Assert.That(connections, Contains.Item(connectionId));
}
[Test]
public void AddConnection_DoesNotDuplicateConnection()
{
// Arrange
var userId = Guid.NewGuid();
var connectionId = "conn1";
// Act
_store.AddConnection(userId, connectionId);
_store.AddConnection(userId, connectionId);
var connections = _store.GetConnections(userId).ToList();
// Assert
Assert.That(connections.Count, Is.EqualTo(1));
}
[Test]
public void RemoveConnection_RemovesConnection()
{
// Arrange
var userId = Guid.NewGuid();
var connectionId = "conn1";
// Act
_store.AddConnection(userId, connectionId);
_store.RemoveConnection(userId, connectionId);
var connections = _store.GetConnections(userId);
// Assert
Assert.That(connections, Is.Empty);
}
[Test]
public void RemoveConnection_LastConnection_RemovesUserEntry()
{
// Arrange
var userId = Guid.NewGuid();
var connectionId = "conn1";
// Act
_store.AddConnection(userId, connectionId);
_store.RemoveConnection(userId, connectionId);
var connections = _store.GetConnections(userId);
// Assert
Assert.That(connections, Is.Empty);
}
[Test]
public void RemoveConnection_NonExistingUser_DoesNotThrow()
{
// Arrange
var userId = Guid.NewGuid();
// Act & Assert
Assert.DoesNotThrow(() =>
_store.RemoveConnection(userId, "conn1"));
}
[Test]
public void GetConnections_NonExistingUser_ReturnsEmpty()
{
// Arrange
var userId = Guid.NewGuid();
// Act
var connections = _store.GetConnections(userId);
// Assert
Assert.That(connections, Is.Empty);
}
[Test]
public void MultipleUsers_IsolatedConnections()
{
// Arrange
var userA = Guid.NewGuid();
var userB = Guid.NewGuid();
// Act
_store.AddConnection(userA, "A1");
_store.AddConnection(userB, "B1");
var connectionsA = _store.GetConnections(userA);
var connectionsB = _store.GetConnections(userB);
// Assert
Assert.That(connectionsA, Contains.Item("A1"));
Assert.That(connectionsB, Contains.Item("B1"));
Assert.That(connectionsA, Does.Not.Contain("B1"));
}
[Test]
public void MultipleConnections_ForSameUser_WorkCorrectly()
{
// Arrange
var userId = Guid.NewGuid();
// Act
_store.AddConnection(userId, "conn1");
_store.AddConnection(userId, "conn2");
var connections = _store.GetConnections(userId).ToList();
// Assert
Assert.That(connections.Count, Is.EqualTo(2));
Assert.That(connections, Contains.Item("conn1"));
Assert.That(connections, Contains.Item("conn2"));
}
}
@@ -0,0 +1,155 @@
using Govor.API.Hubs;
using Govor.API.Hubs.Infrastructure;
using Govor.Application.Interfaces;
using Govor.Core.Models;
using Microsoft.AspNetCore.SignalR;
using Moq;
namespace Govor.API.Tests.IntegrationTests.Hubs.Infrastructure;
[TestFixture]
public class PrivateChatGroupManagerTests
{
private Mock<IConnectionStore> _connectionStoreMock;
private Mock<IHubContext<ChatsHub>> _hubContextMock;
private Mock<IGroupManager> _groupsMock;
private PrivateChatGroupManager _service;
[SetUp]
public void Setup()
{
_connectionStoreMock = new Mock<IConnectionStore>();
_groupsMock = new Mock<IGroupManager>();
_hubContextMock = new Mock<IHubContext<ChatsHub>>();
_hubContextMock
.SetupGet(h => h.Groups)
.Returns(_groupsMock.Object);
_service = new PrivateChatGroupManager(
_connectionStoreMock.Object,
_hubContextMock.Object);
}
[Test]
public async Task AddUsersToPrivateChatGroupAsync_AddsAllConnectionsToGroup()
{
// Arrange
var chat = new PrivateChat
{
Id = Guid.NewGuid(),
UserAId = Guid.NewGuid(),
UserBId = Guid.NewGuid()
};
var connectionsA = new List<string> { "connA1", "connA2" };
var connectionsB = new List<string> { "connB1" };
_connectionStoreMock
.Setup(s => s.GetConnections(chat.UserAId))
.Returns(connectionsA);
_connectionStoreMock
.Setup(s => s.GetConnections(chat.UserBId))
.Returns(connectionsB);
var expectedGroup = ChatHubConstants.GetPrivateChat(chat.Id);
// Act
await _service.AddUsersToPrivateChatGroupAsync(chat);
// Assert
foreach (var conn in connectionsA.Concat(connectionsB))
{
_groupsMock.Verify(
g => g.AddToGroupAsync(conn, expectedGroup, default),
Times.Once);
}
}
[Test]
public async Task RemoveUsersFromPrivateChatGroupAsync_RemovesAllConnectionsFromGroup()
{
// Arrange
var chat = new PrivateChat
{
Id = Guid.NewGuid(),
UserAId = Guid.NewGuid(),
UserBId = Guid.NewGuid()
};
var connectionsA = new List<string> { "connA1" };
var connectionsB = new List<string> { "connB1", "connB2" };
_connectionStoreMock
.Setup(s => s.GetConnections(chat.UserAId))
.Returns(connectionsA);
_connectionStoreMock
.Setup(s => s.GetConnections(chat.UserBId))
.Returns(connectionsB);
var expectedGroup = ChatHubConstants.GetPrivateChat(chat.Id);
// Act
await _service.RemoveUsersFromPrivateChatGroupAsync(chat);
// Assert
foreach (var conn in connectionsA.Concat(connectionsB))
{
_groupsMock.Verify(
g => g.RemoveFromGroupAsync(conn, expectedGroup, default),
Times.Once);
}
}
[Test]
public async Task AddUsersToPrivateChatGroupAsync_NoConnections_DoesNothing()
{
// Arrange
var chat = new PrivateChat
{
Id = Guid.NewGuid(),
UserAId = Guid.NewGuid(),
UserBId = Guid.NewGuid()
};
_connectionStoreMock
.Setup(s => s.GetConnections(It.IsAny<Guid>()))
.Returns(new List<string>());
// Act
await _service.AddUsersToPrivateChatGroupAsync(chat);
// Assert
_groupsMock.Verify(
g => g.AddToGroupAsync(It.IsAny<string>(), It.IsAny<string>(), default),
Times.Never);
}
[Test]
public async Task RemoveUsersFromPrivateChatGroupAsync_NoConnections_DoesNothing()
{
// Arrange
var chat = new PrivateChat
{
Id = Guid.NewGuid(),
UserAId = Guid.NewGuid(),
UserBId = Guid.NewGuid()
};
_connectionStoreMock
.Setup(s => s.GetConnections(It.IsAny<Guid>()))
.Returns(new List<string>());
// Act
await _service.RemoveUsersFromPrivateChatGroupAsync(chat);
// Assert
_groupsMock.Verify(
g => g.RemoveFromGroupAsync(It.IsAny<string>(), It.IsAny<string>(), default),
Times.Never);
}
}
@@ -10,6 +10,7 @@ using Govor.Application.Interfaces.Friends;
using Govor.Application.Interfaces.Infrastructure.Extensions; using Govor.Application.Interfaces.Infrastructure.Extensions;
using Govor.Application.Interfaces.Medias; using Govor.Application.Interfaces.Medias;
using Govor.Application.Interfaces.Messages; using Govor.Application.Interfaces.Messages;
using Govor.Application.Interfaces.PushNotifications;
using Govor.Application.Interfaces.UserOnlineStatus; using Govor.Application.Interfaces.UserOnlineStatus;
using Govor.Application.Interfaces.UserSession; using Govor.Application.Interfaces.UserSession;
using Govor.Application.Interfaces.UserSession.Crypto; using Govor.Application.Interfaces.UserSession.Crypto;
@@ -18,6 +19,8 @@ using Govor.Application.Services.Authentication;
using Govor.Application.Services.Friends; using Govor.Application.Services.Friends;
using Govor.Application.Services.Medias; using Govor.Application.Services.Medias;
using Govor.Application.Services.Messages; using Govor.Application.Services.Messages;
using Govor.Application.Services.PushNotifications;
using Govor.Application.Services.PushNotifications.Providers;
using Govor.Application.Services.UserOnlineStatus; using Govor.Application.Services.UserOnlineStatus;
using Govor.Application.Services.UserSessions; using Govor.Application.Services.UserSessions;
using Govor.Application.Services.UserSessions.Crypto; using Govor.Application.Services.UserSessions.Crypto;
@@ -33,6 +36,7 @@ using Govor.Core.Repositories.Invaites;
using Govor.Core.Repositories.MediasAttachments; using Govor.Core.Repositories.MediasAttachments;
using Govor.Core.Repositories.Messages; using Govor.Core.Repositories.Messages;
using Govor.Core.Repositories.PrivateChats; using Govor.Core.Repositories.PrivateChats;
using Govor.Core.Repositories.PushTokens;
using Govor.Core.Repositories.Users; using Govor.Core.Repositories.Users;
using Govor.Core.Repositories.UserSessionsRepository; using Govor.Core.Repositories.UserSessionsRepository;
using Govor.Data; using Govor.Data;
@@ -45,7 +49,7 @@ public static class ConfigurationProgramExtensions
{ {
public static void AddServices(this IServiceCollection services) public static void AddServices(this IServiceCollection services)
{ {
services.AddScoped<IPasswordHasher, PasswordHasher>(); services.AddSingleton<IPasswordHasher, PasswordHasher>();
services.AddScoped<IJwtTokenHasher, JwtTokenHasher>(); services.AddScoped<IJwtTokenHasher, JwtTokenHasher>();
services.AddScoped<IJwtService, JwtService>(); services.AddScoped<IJwtService, JwtService>();
services.AddScoped<IAccountService, AuthService>(); services.AddScoped<IAccountService, AuthService>();
@@ -74,10 +78,12 @@ public static class ConfigurationProgramExtensions
services.AddMemoryCache(); services.AddMemoryCache();
services.AddScoped<IPingHandlerService, PingHandlerService>(); services.AddScoped<IPingHandlerService, PingHandlerService>();
services.AddScoped<IUserGroupsGetterService, UserGroupsGetterService>();
services.AddScoped<IMessageCommandService, MessageCommandService>(); services.AddScoped<IMessageCommandService, MessageCommandService>();
services.AddScoped<IVerifyFriendship, VerifyFriendship>(); services.AddScoped<IVerifyFriendship, VerifyFriendship>();
services.AddScoped<IUserGroupsGetterService, UserGroupsGetterService>();
services.AddScoped<IUserPrivateChatsGetterService, UserPrivateChatsGetter>(); services.AddScoped<IUserPrivateChatsGetterService, UserPrivateChatsGetter>();
services.AddScoped<IUserPrivateChatsCreator, UserPrivateChatsCreator>();
services.AddScoped<IMessagesLoader, MessagesLoader>(); services.AddScoped<IMessagesLoader, MessagesLoader>();
services.AddScoped<IMediaService, MediaService>(); services.AddScoped<IMediaService, MediaService>();
services.AddScoped<IAccesserToDownloadMedia, AccesserToDownloadMediaService>(); services.AddScoped<IAccesserToDownloadMedia, AccesserToDownloadMediaService>();
@@ -91,9 +97,17 @@ public static class ConfigurationProgramExtensions
services.AddSingleton<IOnlineUserStore, OnlineUserStore>(); services.AddSingleton<IOnlineUserStore, OnlineUserStore>();
// Hubs Infrastructure // Hubs Infrastructure
services.AddScoped<IPrivateChatGroupManager, PrivateChatGroupManager>();
services.AddScoped<IChatNotificationService, ChatNotificationService>(); services.AddScoped<IChatNotificationService, ChatNotificationService>();
services.AddScoped<IConnectionManager, ConnectionManager>(); services.AddScoped<IConnectionManager, ConnectionManager>();
services.AddSingleton<IConnectionStore, ConnectionStore>();
// Pushs
services.AddScoped<IPushNotificationService, PushNotificationService>();
services.AddSingleton<IPushNotificationProvider, FirebasePushProvider>();
// Auto Mapper // Auto Mapper
services.AddAutoMapper(typeof(MappingProfile)); services.AddAutoMapper(typeof(MappingProfile));
@@ -120,6 +134,7 @@ public static class ConfigurationProgramExtensions
services.AddScoped<IPrivateChatsRepository, PrivateChatsRepository>(); services.AddScoped<IPrivateChatsRepository, PrivateChatsRepository>();
services.AddScoped<IGroupsRepository, GroupRepository>(); services.AddScoped<IGroupsRepository, GroupRepository>();
services.AddScoped<IUserSessionsRepository, UserSessionsRepository>(); services.AddScoped<IUserSessionsRepository, UserSessionsRepository>();
services.AddScoped<IPushTokenRepository, PushTokenRepository>();
// other // other
} }
@@ -160,6 +175,4 @@ public static class ConfigurationProgramExtensions
}); });
} }
} }
} }
+32 -18
View File
@@ -1,5 +1,6 @@
using Govor.Application.Interfaces; using Govor.Application.Interfaces;
using Govor.Application.Interfaces.Infrastructure.Extensions; using Govor.Application.Interfaces.Infrastructure.Extensions;
using Govor.Contracts.DTOs;
using Govor.Core.Models; using Govor.Core.Models;
using Govor.Core.Repositories.PrivateChats; using Govor.Core.Repositories.PrivateChats;
using Govor.Core.Repositories.Users; using Govor.Core.Repositories.Users;
@@ -17,7 +18,8 @@ public class PrivateChatController : Controller
private readonly ICurrentUserService _currentUser; private readonly ICurrentUserService _currentUser;
private readonly IUsersRepository _usersRepository; private readonly IUsersRepository _usersRepository;
private readonly IVerifyFriendship _verifyFriendship; private readonly IVerifyFriendship _verifyFriendship;
private readonly IPrivateChatsRepository _privateChats; private readonly IUserPrivateChatsCreator _userPrivateChatsCreator;
private readonly IUserPrivateChatsGetterService _privateChatsGetter;
private readonly ILogger<ChatLoadController> _logger; private readonly ILogger<ChatLoadController> _logger;
public PrivateChatController( public PrivateChatController(
@@ -25,12 +27,15 @@ public class PrivateChatController : Controller
IUsersRepository usersRepository, IUsersRepository usersRepository,
IVerifyFriendship verifyFriendship, IVerifyFriendship verifyFriendship,
IPrivateChatsRepository privateChats, IPrivateChatsRepository privateChats,
IUserPrivateChatsCreator userPrivateChatsCreator,
IUserPrivateChatsGetterService userPrivateChatsGetterService,
ILogger<ChatLoadController> logger) ILogger<ChatLoadController> logger)
{ {
_currentUser = currentUser; _currentUser = currentUser;
_usersRepository = usersRepository; _usersRepository = usersRepository;
_verifyFriendship = verifyFriendship; _verifyFriendship = verifyFriendship;
_privateChats = privateChats; _userPrivateChatsCreator = userPrivateChatsCreator;
_privateChatsGetter = userPrivateChatsGetterService;
_logger = logger; _logger = logger;
} }
@@ -49,9 +54,8 @@ public class PrivateChatController : Controller
await _verifyFriendship.VerifyAsync(currentId, friendId); await _verifyFriendship.VerifyAsync(currentId, friendId);
var chat = await _userPrivateChatsCreator.CreateAsync(currentId, friendId);
var chatId = await GetPrivateChatsIdAsync(currentId, friendId); return Ok(chat.Id);
return Ok(chatId);
} }
catch (UnauthorizedAccessException ex) catch (UnauthorizedAccessException ex)
{ {
@@ -75,21 +79,31 @@ public class PrivateChatController : Controller
} }
} }
private async Task<Guid> GetPrivateChatsIdAsync(Guid userA, Guid userB) [HttpGet("user/private-chats")]
public async Task<IActionResult> GetChatsByFriends()
{ {
Guid recipientId; try
// Makes new PrivateChat if not exist
if (!_privateChats.Exist(userA, userB))
{ {
recipientId = Guid.NewGuid(); var currentId = _currentUser.GetCurrentUserId();
await _privateChats.AddAsync(new PrivateChat() var chats = await _privateChatsGetter.GetUserChatsAsync(currentId);
{ Id = recipientId, UserAId = userA, UserBId = userB });
}
else
{
recipientId = (await _privateChats.GetByMembersAsync(userA, userB)).Id;
}
return recipientId; var result = chats.Select(chat => new PrivateChatDto
{
ChatId = chat.Id,
FriendId = chat.UserAId == currentId ? chat.UserBId : chat.UserAId
}).ToList();
return Ok(result);
}
catch (UnauthorizedAccessException ex)
{
_logger.LogWarning(ex.Message);
return Forbid(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, ex.Message);
return StatusCode(500, "Unexpected Error! Please try again later.");
}
} }
} }
@@ -0,0 +1,59 @@
using Govor.Application.Interfaces.Infrastructure.Extensions;
using Govor.Contracts.Requests;
using Govor.Core.Repositories.PushTokens;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Govor.API.Controllers;
[ApiController]
[Authorize(Roles = "Admin, User")]
[Route("api")]
public class PushTokensController : Controller
{
private readonly ICurrentUserService _currentUser;
private readonly ICurrentUserSessionService _currentSession;
private readonly IPushTokenRepository _pushTokenRepo;
public PushTokensController(
ICurrentUserService currentUser,
ICurrentUserSessionService currentSession,
IPushTokenRepository pushTokenRepository)
{
_currentUser = currentUser;
_pushTokenRepo = pushTokenRepository;
_currentSession = currentSession;
}
[HttpPost("pushes/token/register")]
public async Task<IActionResult> RegisterToken([FromBody] RegisterPushTokenRequest req)
{
try
{
if (!ModelState.IsValid)
return BadRequest(ModelState);
if (string.IsNullOrEmpty(req.Token) || string.IsNullOrWhiteSpace(req.Platform))
return BadRequest(ModelState);
var currentId = _currentUser.GetCurrentUserId();
var currentSessionId = _currentSession.GetUserSessionId();
await _pushTokenRepo.AddOrUpdateTokenAsync(
userId: currentId,
sessionId: currentSessionId,
token: req.Token,
platform: req.Platform);
return Ok();
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
return StatusCode(500, "Unexpected Error! Please try again later.");
}
}
}
@@ -2,6 +2,7 @@ using AutoMapper;
using Govor.Application.Interfaces.Infrastructure.Extensions; using Govor.Application.Interfaces.Infrastructure.Extensions;
using Govor.Application.Interfaces.UserSession; using Govor.Application.Interfaces.UserSession;
using Govor.Contracts.DTOs; using Govor.Contracts.DTOs;
using Govor.Core.Repositories.PushTokens;
using Govor.Data.Repositories.Exceptions; using Govor.Data.Repositories.Exceptions;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
+1
View File
@@ -10,6 +10,7 @@
<PackageReference Include="AutoMapper" Version="12.0.1" /> <PackageReference Include="AutoMapper" Version="12.0.1" />
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" /> <PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" /> <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="FirebaseAdmin" Version="3.4.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.5" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.5" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.0" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.6" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.6" />
@@ -1,5 +1,9 @@
using Govor.Application.Interfaces;
using Govor.Application.Interfaces.PushNotifications;
using Govor.Contracts.Responses.SignalR; using Govor.Contracts.Responses.SignalR;
using Govor.Core.Models.Messages; using Govor.Core.Models.Messages;
using Govor.Core.Repositories.PrivateChats;
using Govor.Core.Repositories.PushTokens;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
namespace Govor.API.Hubs.Infrastructure; namespace Govor.API.Hubs.Infrastructure;
@@ -7,10 +11,19 @@ namespace Govor.API.Hubs.Infrastructure;
public class ChatNotificationService : IChatNotificationService public class ChatNotificationService : IChatNotificationService
{ {
private readonly IHubContext<ChatsHub> _hubContext; private readonly IHubContext<ChatsHub> _hubContext;
private readonly IPrivateChatsRepository _privateChatsRepository;
public ChatNotificationService(IHubContext<ChatsHub> hubContext) private readonly IPushNotificationService _notificationService;
private readonly IProfileService _profileService;
public ChatNotificationService(
IProfileService profileService,
IHubContext<ChatsHub> hubContext,
IPushNotificationService notificationService,
IPrivateChatsRepository privateChatsRepository)
{ {
_hubContext = hubContext; _hubContext = hubContext;
_profileService = profileService;
_notificationService = notificationService;
_privateChatsRepository = privateChatsRepository;
} }
public async Task NotifyMessageSentAsync(UserMessageResponse message) public async Task NotifyMessageSentAsync(UserMessageResponse message)
@@ -19,6 +32,8 @@ private readonly IHubContext<ChatsHub> _hubContext;
{ {
await _hubContext.Clients.Group(ChatHubConstants.GetPrivateChat(message.RecipientId)) await _hubContext.Clients.Group(ChatHubConstants.GetPrivateChat(message.RecipientId))
.SendAsync(ChatHubConstants.ReceiveMessage, message); .SendAsync(ChatHubConstants.ReceiveMessage, message);
await NotifyMessageReceivedInPrivateChatAsync(message);
} }
else else
{ {
@@ -30,6 +45,19 @@ private readonly IHubContext<ChatsHub> _hubContext;
// .SendAsync(ChatHubConstants.MessageSent, message); // .SendAsync(ChatHubConstants.MessageSent, message);
} }
private async Task NotifyMessageReceivedInPrivateChatAsync(UserMessageResponse message)
{
var privateChat = await _privateChatsRepository.GetByIdAsync(message.RecipientId);
var text = message.EncryptedContent.Substring(0, Math.Min(40, message.EncryptedContent.Length));
var userId = message.SenderId == privateChat.UserAId ? privateChat.UserAId : privateChat.UserBId;
var profile = await _profileService.GetUserProfileAsync(userId);
var title = profile.Username;
await _notificationService.SendToUserAsync(userId, title,text, "chat_messages");
}
public async Task NotifyMessageRemovedAsync(MessageRemovedResponse response) public async Task NotifyMessageRemovedAsync(MessageRemovedResponse response)
{ {
await NotifyParticipantsAsync( await NotifyParticipantsAsync(
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using Govor.Application.Interfaces; using Govor.Application.Interfaces;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
@@ -7,11 +8,17 @@ public class ConnectionManager : IConnectionManager
{ {
private readonly IUserGroupsGetterService _userGroupsGetterService; private readonly IUserGroupsGetterService _userGroupsGetterService;
private readonly IUserPrivateChatsGetterService _userPrivateChatsGetterService; private readonly IUserPrivateChatsGetterService _userPrivateChatsGetterService;
private readonly IConnectionStore _connectionStore;
private readonly IHubContext<ChatsHub> _hubContext; private readonly IHubContext<ChatsHub> _hubContext;
public ConnectionManager(IUserGroupsGetterService userGroupsGetterService, IUserPrivateChatsGetterService userPrivateChatsGetterService, IHubContext<ChatsHub> hubContext) public ConnectionManager(
IUserGroupsGetterService userGroupsGetterService,
IConnectionStore connectionStore,
IUserPrivateChatsGetterService userPrivateChatsGetterService,
IHubContext<ChatsHub> hubContext)
{ {
_userGroupsGetterService = userGroupsGetterService; _userGroupsGetterService = userGroupsGetterService;
_connectionStore = connectionStore;
_userPrivateChatsGetterService = userPrivateChatsGetterService; _userPrivateChatsGetterService = userPrivateChatsGetterService;
_hubContext = hubContext; _hubContext = hubContext;
} }
@@ -20,6 +27,7 @@ public class ConnectionManager : IConnectionManager
{ {
// user // user
await _hubContext.Groups.AddToGroupAsync(connectionId, ChatHubConstants.GetUserGroup(userId)); await _hubContext.Groups.AddToGroupAsync(connectionId, ChatHubConstants.GetUserGroup(userId));
_connectionStore.AddConnection(userId, connectionId);
// groups // groups
var userGroups = await _userGroupsGetterService.GetUserGroupsAsync(userId); var userGroups = await _userGroupsGetterService.GetUserGroupsAsync(userId);
@@ -41,6 +49,7 @@ public class ConnectionManager : IConnectionManager
if (userId != Guid.Empty) if (userId != Guid.Empty)
{ {
await _hubContext.Groups.RemoveFromGroupAsync(connectionId, ChatHubConstants.GetUserGroup(userId)); await _hubContext.Groups.RemoveFromGroupAsync(connectionId, ChatHubConstants.GetUserGroup(userId));
_connectionStore.RemoveConnection(userId, connectionId);
} }
} }
} }
@@ -0,0 +1,32 @@
using System.Collections.Concurrent;
using Govor.Application.Interfaces;
namespace Govor.API.Hubs.Infrastructure;
public class ConnectionStore : IConnectionStore
{
private readonly ConcurrentDictionary<Guid, HashSet<string>> _connections = new();
public void AddConnection(Guid userId, string connectionId)
{
var conns = _connections.GetOrAdd(userId, _ => new HashSet<string>());
lock (conns) conns.Add(connectionId);
}
public void RemoveConnection(Guid userId, string connectionId)
{
if (_connections.TryGetValue(userId, out var conns))
{
lock (conns)
{
conns.Remove(connectionId);
if (!conns.Any()) _connections.TryRemove(userId, out _);
}
}
}
public IEnumerable<string> GetConnections(Guid userId)
{
return _connections.TryGetValue(userId, out var conns) ? conns.ToList() : Enumerable.Empty<string>();
}
}
@@ -0,0 +1,41 @@
using Govor.API.Hubs;
using Govor.API.Hubs.Infrastructure;
using Govor.Application.Interfaces;
using Govor.Core.Models;
using Microsoft.AspNetCore.SignalR;
namespace Govor.API.Hubs.Infrastructure;
public class PrivateChatGroupManager : IPrivateChatGroupManager
{
private readonly IConnectionStore _connectionStore;
private readonly IHubContext<ChatsHub> _hubContext;
public PrivateChatGroupManager(IConnectionStore connectionStore, IHubContext<ChatsHub> hubContext)
{
_connectionStore = connectionStore;
_hubContext = hubContext;
}
public async Task AddUsersToPrivateChatGroupAsync(PrivateChat privateChat)
{
var connectionsA = _connectionStore.GetConnections(privateChat.UserAId);
var connectionsB = _connectionStore.GetConnections(privateChat.UserBId);
foreach (var conn in connectionsA.Concat(connectionsB))
{
await _hubContext.Groups.AddToGroupAsync(conn, ChatHubConstants.GetPrivateChat(privateChat.Id));
}
}
public async Task RemoveUsersFromPrivateChatGroupAsync(PrivateChat privateChat)
{
var connectionsA = _connectionStore.GetConnections(privateChat.UserAId);
var connectionsB = _connectionStore.GetConnections(privateChat.UserBId);
foreach (var conn in connectionsA.Concat(connectionsB))
{
await _hubContext.Groups.RemoveFromGroupAsync(conn, ChatHubConstants.GetPrivateChat(privateChat.Id));
}
}
}
+9 -1
View File
@@ -1,4 +1,6 @@
using System.Text; using System.Text;
using FirebaseAdmin;
using Google.Apis.Auth.OAuth2;
using Govor.API.Common.Extensions; using Govor.API.Common.Extensions;
using Govor.API.Hubs; using Govor.API.Hubs;
using Govor.Application.Services.Authentication; using Govor.Application.Services.Authentication;
@@ -15,11 +17,17 @@ var services = builder.Services;
builder.AddLogger();// Serilog builder.AddLogger();// Serilog
#if DEBUG #if DEBUG
builder.Configuration.AddJsonFile("appsettings.Development.json", optional: false, reloadOnChange: true); builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
//builder.Configuration.AddJsonFile("appsettings.Development.json", optional: false, reloadOnChange: true);
#else #else
builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true); builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
#endif #endif
FirebaseApp.Create(new AppOptions()
{
Credential = GoogleCredential.FromFile("secrets/firebase-adminsdk.json")
// или FromStream(File.OpenRead("firebase-adminsdk.json"))
});
builder.Services.AddCors(options => builder.Services.AddCors(options =>
{ {
+1 -1
View File
@@ -6,7 +6,7 @@
} }
}, },
"ConnectionStrings": { "ConnectionStrings": {
"GovorDbContext": "Host=localhost;Port=5432;Database=GovorDb;Username=postgres;Password=stalcker;" "GovorDbContext": "Host=46.19.68.182;Port=5432;Database=default_db;Username=main;Password=fP6%,WzF$S?IUI;"
}, },
"UseMySql": false, "UseMySql": false,
"AllowedHosts": "*", "AllowedHosts": "*",
+1 -1
View File
@@ -6,7 +6,7 @@
} }
}, },
"ConnectionStrings": { "ConnectionStrings": {
"GovorDbContext": "Host=46.19.68.182;Port=5432;Database=default_db;Username=gen_user;Password=p_mxIUCl#G6REV;" "GovorDbContext": "Host=46.19.68.182;Port=5432;Database=default_db;Username=main;Password=fP6%,WzF$S?IUI;"
}, },
"UseMySql": false, "UseMySql": false,
"AllowedHosts": "*", "AllowedHosts": "*",
+13
View File
@@ -0,0 +1,13 @@
{
"type": "service_account",
"project_id": "govor-ad008",
"private_key_id": "c86cedafc5fd26278d812d1eb04281415076b70b",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCxSZEVKcgorEeR\nIMobn6Fa2WDRZTwqm6A/yQ4gMJb673TvOSn7ukAZoV/IoZOR8fz84OdLzoEP0/Gu\nYtCvKdc/rsVdPVnLZZxxpk+cYbrfSstdke55E67tLjtlfF7XZn3OSQ2lRszff80z\n78DHWORaSGUb4UKrB8mnvPFZmM8lDzwmslnskQBB97nIDBxsBdUD2jw2n70kjfM5\nFgNX814PEztSqwC7nNkXl2MZxWSOP7ppjTsJrDfWGhFJ1faCS+sW3KzzI4XHMUyG\nd++Oxs7QK/jcD0/NwDMjMycRv/6WZgn2ItY+EUXza/6NMS+RikXwEiZjkfvte5IU\nkTk3Wb+nAgMBAAECggEAEAQN28QVBXue0YENURaILJ1jrjb/ivfZL7n5kTXihdPZ\nE/1tigSm4sndzdGu86nRSYUF0CbdKPFkLXVW1eQpQmMHCGfQ81uAGRJFseJwT9jB\na3gR+5UoIdwh2Ia2SK5FIjweQ/aT7oLspkr79uZUZsWQgMbGT44aDRG4GfiAih/i\nCye02cBUt6LwYTSyn4toX/Hsnoew60qLsyo6UiyE18eOrrFaK0Rcc8ia8jJ7j4K/\niKywX+PiK9z9dp95G/bpspfPkTh1XE1LEL8ArglnOLSJtbewmOrGK/PODo6EaVIU\nH4uv/0ThIkjDN5PvvZZkBXLdZ/mLyAPa2Xh5hvih0QKBgQDqkSSTAJNQG3UgH3cq\n8So4XuFbsWB4zCDqpBz+WitmKJUzYNhXyTZ3Sx7JbculNFESaX2lmeHTW/7vwa/Z\nZrt6npSlR6Ln3AitCyZRE46tBlaN3kBJvTolRLZvBS+Rq/bYedNyN8Ls18yoQcUj\nsGn1Jetjl3M8QQl7AmnOb2aclwKBgQDBfJJ34ba2LwsQTTwdbCeyCGOAyj59qQaq\nB4oGKb24SDJMbOGa7p17PSwvnRUmdNXmT9QKawBf3H2d3MWBpHW1024Ycg64ZD6Y\nLCpye7wLeNs3OPyFDfpi7uXKbpBA/cjU1uI1eqXC197VmU/BAk6TxlRijY3zK1kU\n9bA0pb2HcQKBgHBV1mg4TFR+8dbSeuWr3YZlmhOpnQP87n6w3dnKISKpqNqUNMfF\n6zmyViotVOvnZDQnJS9bxNTOKAd2gNri4kJVE+cbqZ7Ut6r3vf/yF9AxSt5iY/Ns\nlh4nDB+bIi7nZi5CGcuHfOikaLTj2p++6t+mq1Zkv6FJnFq2yyk8HK49AoGBAJ5N\nADyq4+TsWj3tItjjqxqCuH4feb8vsi8cWfWu4vTJxLU4g+BRh3DT3Lnb5/j9sB9t\ngos/fh/v8qpcfQ8TcebgY/wGHTsJcRjpUZU23OP57kihDCEEDa4xzLmxeb5ipJRW\ndt7QSJxAJ6VUeKbt70ICCvpS3CdueMSoOpDoZUdRAoGBALY0kemgKgLxHDNXZEjI\n3/ndIdea5aI2q2UpZHEdfLcCE1Fb/bVjtx9ZTLsxxm25L7sdqHqxivMxmEMhgxc4\nXzvUtA22i4FIF2g6M/31gWeFtHvJWczeit/FMsw271HxPflVuuf4cxaIcYkccJTd\nQhbyfbGJAhVBR02CeDXM3lOy\n-----END PRIVATE KEY-----\n",
"client_email": "firebase-adminsdk-fbsvc@govor-ad008.iam.gserviceaccount.com",
"client_id": "115697205969744490044",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40govor-ad008.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}
@@ -1,31 +1,38 @@
using AutoFixture; using AutoFixture;
using Govor.Application.Interfaces.Authentication; using Govor.Application.Interfaces.Authentication;
using Govor.Application.Services.Authentication; using Govor.Application.Services.Authentication;
using Microsoft.Extensions.Configuration;
namespace Govor.Application.Tests.Services.Authentication;
[TestFixture] [TestFixture]
public class JwtTokenHasherTests public class JwtTokenHasherTests
{ {
private IJwtTokenHasher _jwtTokenHasher; private IJwtTokenHasher _jwtTokenHasher;
private Fixture _fixture; private Fixture _fixture;
private const string TestSecret = "SuperSecretPepper123!";
[SetUp] [SetUp]
public void StarUp() public void StartUp()
{ {
_fixture = new Fixture(); _fixture = new Fixture();
//_jwtTokenHasher = new JwtTokenHasher();
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
{ "EncryptionOption:Secret", TestSecret }
})
.Build();
_jwtTokenHasher = new JwtTokenHasher(config);
} }
[Test] [Test]
public void Given_Token_When_Hash_Then_Hash_And_Verify_Then_Result_Should_Be_True() public void Given_Token_When_Hash_Then_Verify_Should_Return_True()
{ {
// Arrange // Arrange
string token = _fixture.Create<string>(); string token = _fixture.Create<string>();
// Act // Act
string hash = _jwtTokenHasher.HashToken(token); string hash = _jwtTokenHasher.HashToken(token);
var result = _jwtTokenHasher.VerifyToken(token, hash); var result = _jwtTokenHasher.VerifyToken(token, hash);
// Assert // Assert
@@ -34,7 +41,7 @@ public class JwtTokenHasherTests
} }
[Test] [Test]
public void Given_Token_NotToken_When_Hash_Should_Not_Be_True_Then_Result_Should_Be_False() public void Given_Invalid_Token_When_Verify_Should_Return_False()
{ {
// Arrange // Arrange
string token = _fixture.Create<string>(); string token = _fixture.Create<string>();
@@ -42,10 +49,55 @@ public class JwtTokenHasherTests
// Act // Act
string hash = _jwtTokenHasher.HashToken(token); string hash = _jwtTokenHasher.HashToken(token);
var result = _jwtTokenHasher.VerifyToken(notToken, hash); var result = _jwtTokenHasher.VerifyToken(notToken, hash);
// Assert // Assert
Assert.That(result, Is.False); Assert.That(result, Is.False);
} }
[Test]
public void Given_Same_Token_When_Hash_Multiple_Times_Should_Be_Equal()
{
// Arrange
string token = _fixture.Create<string>();
// Act
string hash1 = _jwtTokenHasher.HashToken(token);
string hash2 = _jwtTokenHasher.HashToken(token);
// Assert
Assert.That(hash1, Is.EqualTo(hash2));
}
[Test]
public void Given_Different_Tokens_When_Hash_Should_Be_Different()
{
// Arrange
string token1 = _fixture.Create<string>();
string token2 = _fixture.Create<string>();
// Act
string hash1 = _jwtTokenHasher.HashToken(token1);
string hash2 = _jwtTokenHasher.HashToken(token2);
// Assert
Assert.That(hash1, Is.Not.EqualTo(hash2));
}
[Test]
public void Given_Null_Secret_In_Config_Should_Use_Default_Secret()
{
// Arrange
var emptyConfig = new ConfigurationBuilder().Build();
var hasherWithDefaultSecret = new JwtTokenHasher(emptyConfig);
string token = _fixture.Create<string>();
// Act
string hash = hasherWithDefaultSecret.HashToken(token);
var result = hasherWithDefaultSecret.VerifyToken(token, hash);
// Assert
Assert.That(result, Is.True);
}
} }
@@ -1,5 +1,6 @@
using AutoFixture; using AutoFixture;
using Govor.Application.Exceptions.FriendsService; using Govor.Application.Exceptions.FriendsService;
using Govor.Application.Interfaces;
using Govor.Application.Interfaces.Friends; using Govor.Application.Interfaces.Friends;
using Govor.Application.Services.Friends; using Govor.Application.Services.Friends;
using Govor.Core.Models; using Govor.Core.Models;
@@ -14,8 +15,8 @@ namespace Govor.Application.Tests.Services.Friends;
public class FriendRequestCommandServiceTests public class FriendRequestCommandServiceTests
{ {
private Fixture _fixture; private Fixture _fixture;
private Mock<IUsersRepository> _usersRepositoryMock;
private Mock<IFriendshipsRepository> _friendshipsRepositoryMock; private Mock<IFriendshipsRepository> _friendshipsRepositoryMock;
private Mock<IUserPrivateChatsCreator> _privateChatsCreatorMock;
private IFriendRequestCommandService _service; private IFriendRequestCommandService _service;
[SetUp] [SetUp]
@@ -28,10 +29,10 @@ public class FriendRequestCommandServiceTests
.ForEach(b => _fixture.Behaviors.Remove(b)); .ForEach(b => _fixture.Behaviors.Remove(b));
_fixture.Behaviors.Add(new OmitOnRecursionBehavior()); _fixture.Behaviors.Add(new OmitOnRecursionBehavior());
_usersRepositoryMock = new Mock<IUsersRepository>();
_friendshipsRepositoryMock = new Mock<IFriendshipsRepository>(); _friendshipsRepositoryMock = new Mock<IFriendshipsRepository>();
_privateChatsCreatorMock = new Mock<IUserPrivateChatsCreator>();
_service = new FriendRequestCommandService(_friendshipsRepositoryMock.Object); _service = new FriendRequestCommandService(_friendshipsRepositoryMock.Object, _privateChatsCreatorMock.Object);
} }
// SendFriendRequestAsync // SendFriendRequestAsync
@@ -55,9 +56,17 @@ public class FriendRequestCommandServiceTests
var fromUserId = Guid.NewGuid(); var fromUserId = Guid.NewGuid();
var toUserId = Guid.NewGuid(); var toUserId = Guid.NewGuid();
var existingFriendship = new Friendship
{
Id = Guid.NewGuid(),
RequesterId = fromUserId,
AddresseeId = toUserId,
Status = FriendshipStatus.Pending
};
_friendshipsRepositoryMock _friendshipsRepositoryMock
.Setup(repo => repo.Exist(fromUserId, toUserId)) .Setup(repo => repo.GetFriendshipAsync(fromUserId, toUserId))
.Returns(true); .ReturnsAsync(existingFriendship);
// Act & Assert // Act & Assert
Assert.ThrowsAsync<RequestAlreadySentException>(() => Assert.ThrowsAsync<RequestAlreadySentException>(() =>
@@ -91,17 +100,30 @@ public class FriendRequestCommandServiceTests
// AcceptFriendRequestAsync // AcceptFriendRequestAsync
[Test] [Test]
public async Task AcceptFriendRequestAsync_ValidRequest_CallsUpdateAsync() public async Task AcceptFriendRequestAsync_ValidRequest_CallsUpdateAsync_AndCreatesPrivateChat()
{ {
// Arrange
var friendship = _fixture.Build<Friendship>() var friendship = _fixture.Build<Friendship>()
.With(f => f.Status, FriendshipStatus.Pending) .With(f => f.Status, FriendshipStatus.Pending)
.Create(); .Create();
var privateChat = new PrivateChat
{
Id = Guid.NewGuid(),
UserAId = friendship.AddresseeId,
UserBId = friendship.RequesterId
};
_friendshipsRepositoryMock _friendshipsRepositoryMock
.Setup(r => r.GetByIdAsync(friendship.Id)) .Setup(r => r.GetByIdAsync(friendship.Id))
.ReturnsAsync(friendship); .ReturnsAsync(friendship);
// Act: вызываем именно Accept, не Send _privateChatsCreatorMock
.Setup(p => p.CreateAsync(friendship.AddresseeId, friendship.RequesterId))
.ReturnsAsync(privateChat);
// Act
await _service.AcceptAsync(friendship.Id, friendship.AddresseeId); await _service.AcceptAsync(friendship.Id, friendship.AddresseeId);
// Assert // Assert
@@ -111,6 +133,11 @@ public class FriendRequestCommandServiceTests
f.AddresseeId == friendship.AddresseeId && f.AddresseeId == friendship.AddresseeId &&
f.Status == FriendshipStatus.Accepted f.Status == FriendshipStatus.Accepted
)), Times.Once); )), Times.Once);
_privateChatsCreatorMock.Verify(p => p.CreateAsync(
friendship.AddresseeId,
friendship.RequesterId
), Times.Once);
} }
[Test] [Test]
@@ -21,9 +21,10 @@ public class MessageCommandServiceTests
private Mock<IMessagesRepository> _mockMessagesRepo; private Mock<IMessagesRepository> _mockMessagesRepo;
private Mock<IUsersRepository> _mockUsersRepo; private Mock<IUsersRepository> _mockUsersRepo;
private Mock<IGroupsRepository> _mockGroupsRepo; private Mock<IGroupsRepository> _mockGroupsRepo;
private Mock<IPrivateChatsRepository> _mockPrivateChatsRepo;
private Mock<IVerifyFriendship> _mockVerifyFriendship; private Mock<IVerifyFriendship> _mockVerifyFriendship;
private Mock<IPrivateChatsRepository> _mockPrivateChats;
private Mock<IMediaService> _mockMediaService; private Mock<IMediaService> _mockMediaService;
private Mock<IUserPrivateChatsCreator> _mockUserPrivateChatCreator;
private Mock<ILogger<MessageCommandService>> _mockLogger; private Mock<ILogger<MessageCommandService>> _mockLogger;
private MessageCommandService _messageService; private MessageCommandService _messageService;
@@ -33,17 +34,19 @@ public class MessageCommandServiceTests
_mockMessagesRepo = new Mock<IMessagesRepository>(); _mockMessagesRepo = new Mock<IMessagesRepository>();
_mockUsersRepo = new Mock<IUsersRepository>(); _mockUsersRepo = new Mock<IUsersRepository>();
_mockGroupsRepo = new Mock<IGroupsRepository>(); _mockGroupsRepo = new Mock<IGroupsRepository>();
_mockPrivateChatsRepo = new Mock<IPrivateChatsRepository>();
_mockVerifyFriendship = new Mock<IVerifyFriendship>(); _mockVerifyFriendship = new Mock<IVerifyFriendship>();
_mockPrivateChats = new Mock<IPrivateChatsRepository>();
_mockMediaService = new Mock<IMediaService>(); _mockMediaService = new Mock<IMediaService>();
_mockUserPrivateChatCreator = new Mock<IUserPrivateChatsCreator>();
_mockLogger = new Mock<ILogger<MessageCommandService>>(); _mockLogger = new Mock<ILogger<MessageCommandService>>();
_messageService = new MessageCommandService( _messageService = new MessageCommandService(
_mockMessagesRepo.Object, _mockMessagesRepo.Object,
_mockUsersRepo.Object, _mockUsersRepo.Object,
_mockGroupsRepo.Object, _mockGroupsRepo.Object,
_mockPrivateChatsRepo.Object,
_mockUserPrivateChatCreator.Object,
_mockVerifyFriendship.Object, _mockVerifyFriendship.Object,
_mockPrivateChats.Object,
_mockMediaService.Object, _mockMediaService.Object,
_mockLogger.Object); _mockLogger.Object);
} }
@@ -56,7 +59,16 @@ public class MessageCommandServiceTests
// Arrange // Arrange
var senderId = Guid.NewGuid(); var senderId = Guid.NewGuid();
var recipientId = Guid.NewGuid(); var recipientId = Guid.NewGuid();
var sendMessageParams = new SendMessage("Hello",
var privateChat = new PrivateChat
{
Id = recipientId,
UserAId = senderId,
UserBId = Guid.NewGuid()
};
var sendParams = new SendMessage(
"Hello",
null, null,
recipientId, recipientId,
RecipientType.User, RecipientType.User,
@@ -64,16 +76,18 @@ public class MessageCommandServiceTests
DateTime.UtcNow, DateTime.UtcNow,
new List<SendMedia>()); new List<SendMedia>());
_mockUsersRepo.Setup(r => r.ExistsByIdAsync(recipientId)).ReturnsAsync(true); _mockPrivateChatsRepo
_mockVerifyFriendship.Setup(v => v.VerifyAsync(senderId, recipientId)).Returns(Task.CompletedTask); .Setup(r => r.GetByIdAsync(recipientId))
_mockMessagesRepo.Setup(r => r.AddAsync(It.IsAny<Message>())).Returns(Task.CompletedTask); .ReturnsAsync(privateChat);
_mockPrivateChats.Setup(c => c.Exist(senderId, recipientId)).Returns(true);
_mockPrivateChats.Setup(c => c.GetByMembersAsync(senderId, recipientId)).ReturnsAsync(new PrivateChat(){Id = recipientId}); _mockMessagesRepo
.Setup(r => r.AddAsync(It.IsAny<Message>()))
.Returns(Task.CompletedTask);
// Act // Act
var result = await _messageService.SendMessageAsync(sendMessageParams); var result = await _messageService.SendMessageAsync(sendParams);
// Assert // Assert
Assert.That(result, Is.Not.Null);
Assert.That(result.IsSuccess, Is.True); Assert.That(result.IsSuccess, Is.True);
Assert.That(result.Exception, Is.Null); Assert.That(result.Exception, Is.Null);
@@ -92,44 +106,43 @@ public class MessageCommandServiceTests
var recipientId = Guid.NewGuid(); var recipientId = Guid.NewGuid();
var sendMediaId = Guid.NewGuid(); var sendMediaId = Guid.NewGuid();
var sendMessageParams = new SendMessage( var privateChat = new PrivateChat
{
Id = recipientId,
UserAId = senderId,
UserBId = recipientId
};
var sendParams = new SendMessage(
"Hello", "Hello",
null, null,
recipientId, recipientId,
RecipientType.User, RecipientType.User,
senderId, senderId,
DateTime.UtcNow, DateTime.UtcNow,
new List<SendMedia> { new SendMedia(sendMediaId, string.Empty) }); new List<SendMedia>
{
new SendMedia(sendMediaId, string.Empty)
});
_mockUsersRepo.Setup(r => r.ExistsByIdAsync(recipientId)).ReturnsAsync(true); _mockPrivateChatsRepo
_mockVerifyFriendship.Setup(v => v.VerifyAsync(senderId, recipientId)).Returns(Task.CompletedTask); .Setup(r => r.GetByIdAsync(recipientId))
_mockMessagesRepo.Setup(r => r.AddAsync(It.IsAny<Message>())).Returns(Task.CompletedTask); .ReturnsAsync(privateChat);
_mockPrivateChats.Setup(c => c.Exist(senderId, recipientId)).Returns(true);
_mockPrivateChats.Setup(c => c.GetByMembersAsync(senderId, recipientId))
.ReturnsAsync(new PrivateChat { Id = recipientId });
// !
_mockMediaService _mockMediaService
.Setup(m => m.AttachToMessageAsync(sendMediaId, It.IsAny<Guid>())) .Setup(m => m.AttachToMessageAsync(sendMediaId, It.IsAny<Guid>()))
.ThrowsAsync(new Exception("Unexpected DB error")); .ThrowsAsync(new Exception("Unexpected DB error"));
// Act // Act
var result = await _messageService.SendMessageAsync(sendMessageParams); var result = await _messageService.SendMessageAsync(sendParams);
// Assert // Assert
Assert.That(result, Is.Not.Null);
Assert.That(result.IsSuccess, Is.False); Assert.That(result.IsSuccess, Is.False);
Assert.That(result.Exception, Is.Not.Null); Assert.That(result.Exception!.Message, Is.EqualTo("Unexpected DB error"));
Assert.That(result.Exception.Message, Is.EqualTo("Unexpected DB error"));
// Проверяем, что сообщение не было сохранено
_mockMessagesRepo.Verify(r => r.AddAsync(It.IsAny<Message>()), Times.Never); _mockMessagesRepo.Verify(r => r.AddAsync(It.IsAny<Message>()), Times.Never);
// Проверяем, что метод прикрепления медиа вызывался
_mockMediaService.Verify(m => m.AttachToMessageAsync(sendMediaId, It.IsAny<Guid>()), Times.Once);
} }
[Test] [Test]
public async Task SendMessageAsync_ToGroup_Success() public async Task SendMessageAsync_ToGroup_Success()
{ {
@@ -162,13 +175,14 @@ public class MessageCommandServiceTests
} }
[Test] [Test]
public async Task SendMessageAsync_ToUser_RecipientNotFound_ReturnsFailure() public async Task SendMessageAsync_ToUser_PrivateChatNotFound_ReturnsFailure()
{ {
// Arrange // Arrange
var senderId = Guid.NewGuid(); var senderId = Guid.NewGuid();
var recipientId = Guid.NewGuid(); var recipientId = Guid.NewGuid();
var sendMessageParams = new SendMessage("Hello", var sendParams = new SendMessage(
"Hello",
null, null,
recipientId, recipientId,
RecipientType.User, RecipientType.User,
@@ -176,51 +190,19 @@ public class MessageCommandServiceTests
DateTime.UtcNow, DateTime.UtcNow,
new List<SendMedia>()); new List<SendMedia>());
_mockUsersRepo.Setup(r => r.ExistsByIdAsync(recipientId)).ReturnsAsync(false); _mockPrivateChatsRepo
.Setup(r => r.GetByIdAsync(recipientId))
.ReturnsAsync((PrivateChat?)null);
// Act // Act
var result = await _messageService.SendMessageAsync(sendMessageParams); var result = await _messageService.SendMessageAsync(sendParams);
// Assert // Assert
Assert.That(result, Is.Not.Null);
Assert.That(result.IsSuccess, Is.False); Assert.That(result.IsSuccess, Is.False);
Assert.That(result.Exception, Is.Not.Null);
Assert.That(result.Exception, Is.TypeOf<KeyNotFoundException>()); Assert.That(result.Exception, Is.TypeOf<KeyNotFoundException>());
Assert.That(result.Message, Is.Null); 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<SendMedia>()
);
_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<FriendshipException>());
Assert.That(result.Message, Is.Null);
}
// Test for EditMessageAsync action // Test for EditMessageAsync action
[Test] [Test]
public async Task EditMessageAsync_Success() public async Task EditMessageAsync_Success()
@@ -43,20 +43,29 @@ public class MessagesLoaderTests
// Arrange // Arrange
var chatId = Guid.NewGuid(); var chatId = Guid.NewGuid();
_privateChatsRepoMock.Setup(r => r.Exist(_currentUserId, _otherUserId)).Returns(true); _privateChatsRepoMock
_privateChatsRepoMock.Setup(r => r.GetByMembersAsync(_currentUserId, _otherUserId)) .Setup(r => r.Exist(chatId))
.ReturnsAsync(new PrivateChat() { Id = chatId }); .Returns(true);
var messages = new List<Message> var messages = new List<Message>
{ {
new() { Id = Guid.NewGuid(), RecipientId = chatId, RecipientType = RecipientType.User } new()
{
Id = Guid.NewGuid(),
RecipientId = chatId,
RecipientType = RecipientType.User,
SentAt = DateTime.UtcNow
}
}; };
await _dbContext.Messages.AddRangeAsync(messages); await _dbContext.Messages.AddRangeAsync(messages);
await _dbContext.SaveChangesAsync(); await _dbContext.SaveChangesAsync();
// Act // Act
var result = await _loader.LoadMessagesInUserChat(_currentUserId, _otherUserId, null); var result = await _loader.LoadMessagesInUserChat(
chatId,
_currentUserId,
null);
// Assert // Assert
Assert.That(result, Has.Count.EqualTo(1)); Assert.That(result, Has.Count.EqualTo(1));
@@ -66,11 +75,17 @@ public class MessagesLoaderTests
public async Task LoadLastMessagesInUserChat_ReturnsEmpty_WhenNoMessages() public async Task LoadLastMessagesInUserChat_ReturnsEmpty_WhenNoMessages()
{ {
// Arrange // Arrange
_privateChatsRepoMock.Setup(r => r.Exist(_currentUserId, _otherUserId)).Returns(true); var chatId = Guid.NewGuid();
_privateChatsRepoMock.Setup(r => r.GetByMembersAsync(_currentUserId, _otherUserId))
.ReturnsAsync(new PrivateChat { Id = Guid.NewGuid() }); _privateChatsRepoMock
.Setup(r => r.Exist(chatId))
.Returns(true);
// Act // Act
var result = await _loader.LoadMessagesInUserChat(_currentUserId, _otherUserId, null); var result = await _loader.LoadMessagesInUserChat(
chatId,
_currentUserId,
null);
// Assert // Assert
Assert.That(result, Is.Empty); Assert.That(result, Is.Empty);
@@ -83,7 +98,7 @@ public class MessagesLoaderTests
var ex = Assert.ThrowsAsync<ArgumentException>(async () => var ex = Assert.ThrowsAsync<ArgumentException>(async () =>
await _loader.LoadMessagesInUserChat(Guid.Empty, _currentUserId, null)); await _loader.LoadMessagesInUserChat(Guid.Empty, _currentUserId, null));
Assert.That(ex.Message, Does.Contain("User id cannot be empty")); Assert.That(ex.Message, Does.Contain("PrivateChatId id cannot be empty"));
} }
[Test] [Test]
@@ -0,0 +1,183 @@
using Govor.Application.Interfaces.PushNotifications;
using Govor.Application.Interfaces.PushNotifications.Models;
using Govor.Application.Services.PushNotifications;
using Govor.Core.Repositories.PushTokens;
using Microsoft.Extensions.Logging;
using Moq;
namespace Govor.Application.Tests.Services.PushNotifications;
[TestFixture]
public class PushNotificationServiceTests
{
private Mock<IPushNotificationProvider> _providerMock;
private Mock<IPushTokenRepository> _tokenRepoMock;
private Mock<ILogger<PushNotificationService>> _loggerMock;
private PushNotificationService _service;
[SetUp]
public void Setup()
{
_providerMock = new Mock<IPushNotificationProvider>(MockBehavior.Strict);
_tokenRepoMock = new Mock<IPushTokenRepository>(MockBehavior.Strict);
_loggerMock = new Mock<ILogger<PushNotificationService>>();
_service = new PushNotificationService(
_providerMock.Object,
_tokenRepoMock.Object,
_loggerMock.Object);
}
#region SendToUserAsync
[Test]
public async Task SendToUserAsync_NoTokens_DoesNotCallProvider()
{
// Arrange
var userId = Guid.NewGuid();
_tokenRepoMock
.Setup(r => r.GetActiveTokensAsync(userId))
.ReturnsAsync(new List<string>());
// Act
await _service.SendToUserAsync(userId, "title", "body", "chat");
// Assert
_providerMock.Verify(
p => p.SendMulticastAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<PushMessage>()),
Times.Never);
}
[Test]
public async Task SendToUserAsync_SuccessfulSend_CallsProvider()
{
// Arrange
var userId = Guid.NewGuid();
var tokens = new List<string> { "t1", "t2" };
_tokenRepoMock
.Setup(r => r.GetActiveTokensAsync(userId))
.ReturnsAsync(tokens);
_providerMock
.Setup(p => p.SendMulticastAsync(tokens, It.IsAny<PushMessage>()))
.ReturnsAsync(new SendPushResult(0, 0, []));
// Act
await _service.SendToUserAsync(userId, "title", "body", "chat");
// Assert
_providerMock.Verify(
p => p.SendMulticastAsync(tokens, It.IsAny<PushMessage>()),
Times.Once);
_tokenRepoMock.Verify(
r => r.RemoveTokensAsync(It.IsAny<IEnumerable<string>>()),
Times.Never);
}
[Test]
public async Task SendToUserAsync_WithFailures_RemovesInvalidTokens()
{
// Arrange
var userId = Guid.NewGuid();
var tokens = new List<string> { "t1", "t2" };
var failed = new List<string> { "t2" };
_tokenRepoMock
.Setup(r => r.GetActiveTokensAsync(userId))
.ReturnsAsync(tokens);
_providerMock
.Setup(p => p.SendMulticastAsync(tokens, It.IsAny<PushMessage>()))
.ReturnsAsync(new SendPushResult(0, 1, failed));
_tokenRepoMock
.Setup(r => r.RemoveTokensAsync(failed))
.Returns(Task.CompletedTask);
// Act
await _service.SendToUserAsync(userId, "title", "body", "chat");
// Assert
_tokenRepoMock.Verify(r => r.RemoveTokensAsync(failed), Times.Once);
}
#endregion
#region SendToUsersAsync
[Test]
public async Task SendToUsersAsync_NoTokens_DoesNotCallProvider()
{
// Arrange
var users = new[] { Guid.NewGuid() };
_tokenRepoMock
.Setup(r => r.GetActiveTokensUsersAsync(users))
.ReturnsAsync(new List<string>());
// Act
await _service.SendToUsersAsync(users, "title", "body", "chat");
// Assert
_providerMock.Verify(
p => p.SendMulticastAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<PushMessage>()),
Times.Never);
}
#endregion
#region SendToSessionAsync
[Test]
public async Task SendToSessionAsync_NoToken_DoesNothing()
{
// Arrange
var sessionId = Guid.NewGuid();
_tokenRepoMock
.Setup(r => r.GetActiveTokenBySessionAsync(sessionId))
.ReturnsAsync("");
// Act
await _service.SendToSessionAsync(sessionId, "title", "body", "chat");
// Assert
_providerMock.Verify(
p => p.SendToTokenAsync(It.IsAny<string>(), It.IsAny<PushMessage>()),
Times.Never);
}
[Test]
public async Task SendToSessionAsync_WithFailure_RemovesToken()
{
// Arrange
var sessionId = Guid.NewGuid();
var token = "t1";
_tokenRepoMock
.Setup(r => r.GetActiveTokenBySessionAsync(sessionId))
.ReturnsAsync(token);
_providerMock
.Setup(p => p.SendToTokenAsync(token, It.IsAny<PushMessage>()))
.ReturnsAsync(new SendPushResult(0, 1, [token]));
_tokenRepoMock
.Setup(r => r.RemoveTokensAsync(It.IsAny<IEnumerable<string>>()))
.Returns(Task.CompletedTask);
// Act
await _service.SendToSessionAsync(sessionId, "title", "body", "chat");
// Assert
_tokenRepoMock.Verify(
r => r.RemoveTokensAsync(It.Is<IEnumerable<string>>(x => x.Contains(token))),
Times.Once);
}
#endregion
}
@@ -0,0 +1,108 @@
using AutoFixture;
using Govor.Application.Interfaces;
using Govor.Application.Services;
using Govor.Core.Models;
using Govor.Core.Repositories.PrivateChats;
using Microsoft.Extensions.Logging;
using Moq;
namespace Govor.Application.Tests.Services;
[TestFixture]
public class UserPrivateChatsCreatorTests
{
private Fixture _fixture;
private Mock<ILogger<UserPrivateChatsCreator>> _mockLogger;
private Mock<IPrivateChatsRepository> _mockPrivateChats;
private Mock<IPrivateChatGroupManager> _mockPrivateChatGroupManager;
private IUserPrivateChatsCreator _service;
[SetUp]
public void SetUp()
{
_fixture = new Fixture();
_fixture.Behaviors
.OfType<ThrowingRecursionBehavior>()
.ToList()
.ForEach(b => _fixture.Behaviors.Remove(b));
_fixture.Behaviors.Add(new OmitOnRecursionBehavior());
_mockLogger = new Mock<ILogger<UserPrivateChatsCreator>>();
_mockPrivateChats = new Mock<IPrivateChatsRepository>();
_mockPrivateChatGroupManager = new Mock<IPrivateChatGroupManager>();
_service = new UserPrivateChatsCreator(
_mockPrivateChats.Object,
_mockPrivateChatGroupManager.Object,
_mockLogger.Object);
}
[Test]
public async Task CreateAsync_ShouldCreateNewChat_WhenChatDoesNotExist()
{
// Arrange
var userA = Guid.NewGuid();
var userB = Guid.NewGuid();
_mockPrivateChats
.Setup(x => x.Exist(userA, userB))
.Returns(false);
PrivateChat? addedChat = null;
_mockPrivateChats
.Setup(x => x.AddAsync(It.IsAny<PrivateChat>()))
.Callback<PrivateChat>(chat => addedChat = chat)
.Returns(Task.CompletedTask);
// Act
var result = await _service.CreateAsync(userA, userB);
// Assert
_mockPrivateChats.Verify(x => x.AddAsync(It.IsAny<PrivateChat>()), Times.Once);
_mockPrivateChatGroupManager.Verify(x => x.AddUsersToPrivateChatGroupAsync(It.IsAny<PrivateChat>()), Times.Once);
Assert.That(result, Is.Not.Null);
Assert.That(result.UserAId, Is.EqualTo(userA));
Assert.That(result.UserBId, Is.EqualTo(userB));
Assert.That(result.Id, Is.Not.EqualTo(Guid.Empty));
Assert.That(addedChat, Is.Not.Null);
Assert.That(addedChat!.Id, Is.EqualTo(result.Id));
}
[Test]
public async Task CreateAsync_ShouldReturnExistingChat_WhenChatAlreadyExists()
{
// Arrange
var userA = Guid.NewGuid();
var userB = Guid.NewGuid();
var existingChat = _fixture.Build<PrivateChat>()
.With(x => x.UserAId, userA)
.With(x => x.UserBId, userB)
.Create();
_mockPrivateChats
.Setup(x => x.Exist(userA, userB))
.Returns(true);
_mockPrivateChats
.Setup(x => x.GetByMembersAsync(userA, userB))
.ReturnsAsync(existingChat);
// Act
var result = await _service.CreateAsync(userA, userB);
// Assert
_mockPrivateChats.Verify(x => x.AddAsync(It.IsAny<PrivateChat>()), Times.Never);
_mockPrivateChatGroupManager.Verify(
x => x.AddUsersToPrivateChatGroupAsync(It.IsAny<PrivateChat>()),
Times.Never);
_mockPrivateChats.Verify(x => x.GetByMembersAsync(userA, userB), Times.Once);
Assert.That(result, Is.EqualTo(existingChat));
}
}
@@ -1,6 +1,7 @@
using Govor.Application.Services.UserSessions; using Govor.Application.Services.UserSessions;
using Govor.Core.Models; using Govor.Core.Models;
using Govor.Core.Models.Users; using Govor.Core.Models.Users;
using Govor.Core.Repositories.PushTokens;
using Govor.Core.Repositories.UserSessionsRepository; using Govor.Core.Repositories.UserSessionsRepository;
using Govor.Data.Repositories.Exceptions; using Govor.Data.Repositories.Exceptions;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -14,6 +15,7 @@ public class UserSessionRevokerTests
{ {
private Mock<IUserSessionsRepository> _sessionsRepositoryMock; private Mock<IUserSessionsRepository> _sessionsRepositoryMock;
private Mock<ILogger<UserSessionRevoker>> _loggerMock; private Mock<ILogger<UserSessionRevoker>> _loggerMock;
private Mock<IPushTokenRepository> _pushTokenRepositoryMock;
private UserSessionRevoker _revoker; private UserSessionRevoker _revoker;
[SetUp] [SetUp]
@@ -21,7 +23,8 @@ public class UserSessionRevokerTests
{ {
_sessionsRepositoryMock = new Mock<IUserSessionsRepository>(); _sessionsRepositoryMock = new Mock<IUserSessionsRepository>();
_loggerMock = new Mock<ILogger<UserSessionRevoker>>(); _loggerMock = new Mock<ILogger<UserSessionRevoker>>();
_revoker = new UserSessionRevoker(_sessionsRepositoryMock.Object, _loggerMock.Object); _pushTokenRepositoryMock = new Mock<IPushTokenRepository>();
_revoker = new UserSessionRevoker(_sessionsRepositoryMock.Object, _pushTokenRepositoryMock.Object, _loggerMock.Object);
} }
[Test] [Test]
@@ -39,6 +42,7 @@ public class UserSessionRevokerTests
// Assert // Assert
Assert.That(session.IsRevoked, Is.True); Assert.That(session.IsRevoked, Is.True);
_sessionsRepositoryMock.Verify(r => r.UpdateAsync(session), Times.Once()); _sessionsRepositoryMock.Verify(r => r.UpdateAsync(session), Times.Once());
_pushTokenRepositoryMock.Verify(r => r.DeactivateTokenBySessionAsync(sessionId), Times.Once());
_loggerMock.VerifyNoOtherCalls(); _loggerMock.VerifyNoOtherCalls();
} }
@@ -56,13 +60,16 @@ public class UserSessionRevokerTests
var ex = Assert.ThrowsAsync<UnauthorizedAccessException>(() => var ex = Assert.ThrowsAsync<UnauthorizedAccessException>(() =>
_revoker.CloseSessionByIdAsync(sessionId, differentUserId)); _revoker.CloseSessionByIdAsync(sessionId, differentUserId));
Assert.That(ex.Message, Contains.Substring($"User {differentUserId} does not belong to this session {sessionId}")); Assert.That(ex.Message, Contains.Substring($"User {differentUserId} does not belong to this session {sessionId}"));
_loggerMock.Verify(l => l.Log( _loggerMock.Verify(l => l.Log(
LogLevel.Warning, LogLevel.Warning,
It.IsAny<EventId>(), It.IsAny<EventId>(),
It.IsAny<It.IsAnyType>(), It.IsAny<It.IsAnyType>(),
It.IsAny<Exception>(), It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception, string>>()), Times.Once()); It.IsAny<Func<It.IsAnyType, Exception, string>>()), Times.Once());
_sessionsRepositoryMock.Verify(r => r.UpdateAsync(It.IsAny<UserSession>()), Times.Never()); _sessionsRepositoryMock.Verify(r => r.UpdateAsync(It.IsAny<UserSession>()), Times.Never());
_pushTokenRepositoryMock.Verify(r => r.DeactivateTokenBySessionAsync(sessionId), Times.Never());
} }
[Test] [Test]
@@ -79,6 +86,7 @@ public class UserSessionRevokerTests
// Assert // Assert
_sessionsRepositoryMock.Verify(r => r.UpdateAsync(It.IsAny<UserSession>()), Times.Never()); _sessionsRepositoryMock.Verify(r => r.UpdateAsync(It.IsAny<UserSession>()), Times.Never());
_pushTokenRepositoryMock.Verify(r => r.DeactivateTokenBySessionAsync(sessionId), Times.Never());
_loggerMock.VerifyNoOtherCalls(); _loggerMock.VerifyNoOtherCalls();
} }
@@ -102,6 +110,7 @@ public class UserSessionRevokerTests
It.IsAny<Exception>(), It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception, string>>()), Times.Once()); It.IsAny<Func<It.IsAnyType, Exception, string>>()), Times.Once());
_sessionsRepositoryMock.Verify(r => r.UpdateAsync(It.IsAny<UserSession>()), Times.Never()); _sessionsRepositoryMock.Verify(r => r.UpdateAsync(It.IsAny<UserSession>()), Times.Never());
_pushTokenRepositoryMock.Verify(r => r.DeactivateTokenBySessionAsync(sessionId), Times.Never());
} }
[Test] [Test]
@@ -126,7 +135,11 @@ public class UserSessionRevokerTests
Assert.That(sessions[2].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[0]), Times.Once());
_sessionsRepositoryMock.Verify(r => r.UpdateAsync(sessions[2]), Times.Once()); _sessionsRepositoryMock.Verify(r => r.UpdateAsync(sessions[2]), Times.Once());
_pushTokenRepositoryMock.Verify(r => r.DeactivateTokenBySessionAsync(sessions[0].Id), Times.Once());
_pushTokenRepositoryMock.Verify(r => r.DeactivateTokenBySessionAsync(sessions[2].Id), Times.Once());
_sessionsRepositoryMock.Verify(r => r.UpdateAsync(sessions[1]), Times.Never()); _sessionsRepositoryMock.Verify(r => r.UpdateAsync(sessions[1]), Times.Never());
_pushTokenRepositoryMock.Verify(r => r.DeactivateTokenBySessionAsync(sessions[1].Id), Times.Never);
_loggerMock.VerifyNoOtherCalls(); _loggerMock.VerifyNoOtherCalls();
} }
@@ -148,6 +161,8 @@ public class UserSessionRevokerTests
It.IsAny<It.IsAnyType>(), It.IsAny<It.IsAnyType>(),
It.IsAny<Exception>(), It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception, string>>()), Times.Once()); It.IsAny<Func<It.IsAnyType, Exception, string>>()), Times.Once());
_pushTokenRepositoryMock.Verify(r => r.DeactivateTokenBySessionAsync(It.IsAny<Guid>()), Times.Never);
_sessionsRepositoryMock.Verify(r => r.UpdateAsync(It.IsAny<UserSession>()), Times.Never()); _sessionsRepositoryMock.Verify(r => r.UpdateAsync(It.IsAny<UserSession>()), Times.Never());
} }
} }
@@ -13,6 +13,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" /> <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="FirebaseAdmin" Version="3.4.0" />
<PackageReference Include="Microsoft.AspNetCore.Hosting" Version="2.3.0" /> <PackageReference Include="Microsoft.AspNetCore.Hosting" Version="2.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.0" /> <PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.0" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.0.1" /> <PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.0.1" />
@@ -0,0 +1,8 @@
namespace Govor.Application.Interfaces;
public interface IConnectionStore
{
void AddConnection(Guid userId, string connectionId);
void RemoveConnection(Guid userId, string connectionId);
IEnumerable<string> GetConnections(Guid userId);
}
@@ -0,0 +1,9 @@
using Govor.Core.Models;
namespace Govor.Application.Interfaces;
public interface IPrivateChatGroupManager
{
Task AddUsersToPrivateChatGroupAsync(PrivateChat privateChat);
Task RemoveUsersFromPrivateChatGroupAsync(PrivateChat privateChat);
}
@@ -0,0 +1,8 @@
using Govor.Core.Models;
namespace Govor.Application.Interfaces;
public interface IUserPrivateChatsCreator
{
Task<PrivateChat> CreateAsync(Guid userIdA, Guid userIdB);
}
@@ -0,0 +1,14 @@
using Govor.Application.Interfaces.PushNotifications.Models;
namespace Govor.Application.Interfaces.PushNotifications;
public interface IPushNotificationProvider
{
string Name { get; }
Task<SendPushResult> SendToTokenAsync(string token, PushMessage message);
Task<SendPushResult> SendMulticastAsync(
IReadOnlyList<string> tokens,
PushMessage message);
}
@@ -0,0 +1,10 @@
namespace Govor.Application.Interfaces.PushNotifications;
public interface IPushNotificationService
{
Task SendToUserAsync(Guid userId, string title, string body, string channelId, Dictionary<string, string>? data = null);
Task SendToUsersAsync(IEnumerable<Guid> userIds, string title, string body, string channelId, Dictionary<string, string>? data = null);
Task SendToSessionAsync(Guid sessionId, string title, string body, string channelId, Dictionary<string, string>? data = null);
}
@@ -0,0 +1,9 @@
namespace Govor.Application.Interfaces.PushNotifications.Models;
public class PushMessage
{
public string Title { get; set; } = string.Empty;
public string Body { get; set; } = string.Empty;
public Dictionary<string, string> Data { get; set; } = new();
public string? ChannelId { get; set; } = "chat_messages"; //for Android
}
@@ -0,0 +1,3 @@
namespace Govor.Application.Interfaces.PushNotifications.Models;
public record SendPushResult(int SuccessCount, int FailureCount, IEnumerable<string> FailedTokens);
@@ -1,4 +1,5 @@
using Govor.Application.Exceptions.FriendsService; using Govor.Application.Exceptions.FriendsService;
using Govor.Application.Interfaces;
using Govor.Application.Interfaces.Friends; using Govor.Application.Interfaces.Friends;
using Govor.Core.Models; using Govor.Core.Models;
using Govor.Core.Repositories.Friendships; using Govor.Core.Repositories.Friendships;
@@ -9,10 +10,13 @@ namespace Govor.Application.Services.Friends;
public class FriendRequestCommandService : IFriendRequestCommandService public class FriendRequestCommandService : IFriendRequestCommandService
{ {
private readonly IFriendshipsRepository _friendshipsRepository; private readonly IFriendshipsRepository _friendshipsRepository;
private readonly IUserPrivateChatsCreator _privateChatsCreator;
public FriendRequestCommandService(IFriendshipsRepository friendshipsRepository) public FriendRequestCommandService(
IFriendshipsRepository friendshipsRepository,
IUserPrivateChatsCreator privateChatsCreator)
{ {
_friendshipsRepository = friendshipsRepository; _friendshipsRepository = friendshipsRepository;
_privateChatsCreator = privateChatsCreator;
} }
public async Task<Friendship> SendAsync(Guid fromUserId, Guid toUserId) public async Task<Friendship> SendAsync(Guid fromUserId, Guid toUserId)
@@ -57,6 +61,7 @@ public class FriendRequestCommandService : IFriendRequestCommandService
{ {
var friendship = await _friendshipsRepository.GetByIdAsync(requestId); var friendship = await _friendshipsRepository.GetByIdAsync(requestId);
if (friendship.AddresseeId != currentUserId) if (friendship.AddresseeId != currentUserId)
throw new UnauthorizedAccessException("You cannot accept this request"); throw new UnauthorizedAccessException("You cannot accept this request");
@@ -66,6 +71,8 @@ public class FriendRequestCommandService : IFriendRequestCommandService
friendship.Status = FriendshipStatus.Accepted; friendship.Status = FriendshipStatus.Accepted;
await _friendshipsRepository.UpdateAsync(friendship); await _friendshipsRepository.UpdateAsync(friendship);
await _privateChatsCreator.CreateAsync(friendship.AddresseeId, friendship.RequesterId);
return friendship; return friendship;
} }
catch (NotFoundByKeyException<Guid> ex) catch (NotFoundByKeyException<Guid> ex)
@@ -16,9 +16,10 @@ namespace Govor.Application.Services.Messages;
public class MessageCommandService : IMessageCommandService public class MessageCommandService : IMessageCommandService
{ {
private readonly IMessagesRepository _messagesRepository; private readonly IMessagesRepository _messagesRepository;
private readonly IPrivateChatsRepository _privateChatsRepository;
private readonly IUsersRepository _usersRepository; private readonly IUsersRepository _usersRepository;
private readonly IGroupsRepository _groupsRepository; private readonly IGroupsRepository _groupsRepository;
private readonly IPrivateChatsRepository _privateChats; private readonly IUserPrivateChatsCreator _privateChatsCreator;
private readonly IVerifyFriendship _verifyFriendship; private readonly IVerifyFriendship _verifyFriendship;
private readonly IMediaService _mediaService; private readonly IMediaService _mediaService;
private readonly ILogger<MessageCommandService> _logger; private readonly ILogger<MessageCommandService> _logger;
@@ -27,70 +28,32 @@ public class MessageCommandService : IMessageCommandService
IMessagesRepository messagesRepository, IMessagesRepository messagesRepository,
IUsersRepository usersRepository, IUsersRepository usersRepository,
IGroupsRepository groupsRepository, IGroupsRepository groupsRepository,
IPrivateChatsRepository privateChatsRepository,
IUserPrivateChatsCreator privateChatsCreator,
IVerifyFriendship verifyFriendship, IVerifyFriendship verifyFriendship,
IPrivateChatsRepository privateChats,
IMediaService mediaService, IMediaService mediaService,
ILogger<MessageCommandService> logger) ILogger<MessageCommandService> logger)
{ {
_messagesRepository = messagesRepository; _messagesRepository = messagesRepository;
_usersRepository = usersRepository; _usersRepository = usersRepository;
_groupsRepository = groupsRepository; _groupsRepository = groupsRepository;
_privateChatsRepository = privateChatsRepository;
_privateChatsCreator = privateChatsCreator;
_verifyFriendship = verifyFriendship; _verifyFriendship = verifyFriendship;
_privateChats = privateChats;
_mediaService = mediaService; _mediaService = mediaService;
_logger = logger; _logger = logger;
} }
public async Task<SendMessageResult> SendMessageAsync(SendMessage sendParams) public async Task<SendMessageResult> SendMessageAsync(SendMessage sendParams)
{ {
Guid recipientId;
try try
{ {
// Validate recipient var recipientId = sendParams.RecipientType switch
if (sendParams.RecipientType == RecipientType.User)
{ {
if (!await _usersRepository.ExistsByIdAsync(sendParams.RecipientId)) RecipientType.User => await HandleUserRecipientAsync(sendParams),
{ RecipientType.Group => await HandleGroupRecipientAsync(sendParams),
if (!_privateChats.Exist(sendParams.RecipientId)) _ => throw new ArgumentException("Invalid recipient type.")
{ };
_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);
}
else
{
recipientId = sendParams.RecipientId;
}
}
else
{
// 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 messageId = Guid.NewGuid();
var message = new Message var message = new Message
@@ -111,44 +74,61 @@ public class MessageCommandService : IMessageCommandService
}).ToList() ?? new List<MediaAttachments>() }).ToList() ?? new List<MediaAttachments>()
}; };
// If there is media, we link them. // Attach media
if (sendParams.Media != null && sendParams.Media.Any()) if (sendParams.Media?.Any() == true)
{
foreach (var media in sendParams.Media) foreach (var media in sendParams.Media)
{
await _mediaService.AttachToMessageAsync(media.MediaId, messageId); await _mediaService.AttachToMessageAsync(media.MediaId, messageId);
}
}
await _messagesRepository.AddAsync(message); await _messagesRepository.AddAsync(message);
_logger.LogInformation("Message {MessageId} from {SenderId} to {RecipientId} ({RecipientType}) saved successfully.", messageId, sendParams.FromUserId, sendParams.RecipientId, sendParams.RecipientType); _logger.LogInformation(
"Message {MessageId} from {SenderId} to {RecipientId} ({RecipientType}) saved successfully.",
messageId, sendParams.FromUserId, sendParams.RecipientId, sendParams.RecipientType);
return new SendMessageResult(true, null, message); return new SendMessageResult(true, null, message);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error sending message from {SenderId} to {RecipientId} ({RecipientType})", sendParams.FromUserId, sendParams.RecipientId, sendParams.RecipientType); _logger.LogError(
ex,
"Error sending message from {SenderId} to {RecipientId} ({RecipientType})",
sendParams.FromUserId, sendParams.RecipientId, sendParams.RecipientType);
return new SendMessageResult(false, ex, default); return new SendMessageResult(false, ex, default);
} }
} }
private async Task<Guid> GetPrivateChatsIdAsync(Guid userA, Guid userB) private async Task<Guid> HandleUserRecipientAsync(SendMessage sendParams)
{ {
Guid recipientId; var privateChat = await _privateChatsRepository.GetByIdAsync(sendParams.RecipientId);
// Makes new PrivateChat if not exist
if (!_privateChats.Exist(userA, userB)) if (privateChat == null)
{ {
recipientId = Guid.NewGuid(); _logger.LogWarning("Private chat {ChatId} not found", sendParams.RecipientId);
await _privateChats.AddAsync(new PrivateChat() throw new KeyNotFoundException($"Private chat {sendParams.RecipientId} not found.");
{ Id = recipientId, UserAId = userA, UserBId = userB });
}
else
{
recipientId = (await _privateChats.GetByMembersAsync(userA, userB)).Id;
} }
return recipientId; return privateChat.Id;
} }
private async Task<Guid> HandleGroupRecipientAsync(SendMessage sendParams)
{
if (!_groupsRepository.Exist(sendParams.RecipientId))
{
_logger.LogWarning("Attempt to send message to non-existent group {GroupId}", sendParams.RecipientId);
throw new KeyNotFoundException($"Recipient group {sendParams.RecipientId} not found.");
}
var 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);
throw new UnauthorizedAccessException("Sender is not a member of the group.");
}
return sendParams.RecipientId;
}
//TODO: Full Cleanup all them:
public async Task<EditMessageResult> EditMessageAsync(EditMessage editParams) public async Task<EditMessageResult> EditMessageAsync(EditMessage editParams)
{ {
try try
@@ -157,8 +137,11 @@ public class MessageCommandService : IMessageCommandService
if (message.SenderId != editParams.EditorId) 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); _logger.LogWarning(
return new EditMessageResult(false, new UnauthorizedAccessException("User is not authorized to edit this message."), null); "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)) /*if (message.SentAt < DateTime.UtcNow.AddMinutes(-15))
@@ -174,7 +157,7 @@ public class MessageCommandService : IMessageCommandService
ReplyToMessageId = message.ReplyToMessageId, ReplyToMessageId = message.ReplyToMessageId,
Reactions = message.Reactions, Reactions = message.Reactions,
MediaAttachments = message.MediaAttachments, MediaAttachments = message.MediaAttachments,
MessageViews = message.MessageViews, MessageViews = message.MessageViews
}; };
message.EncryptedContent = editParams.NewContent; message.EncryptedContent = editParams.NewContent;
@@ -182,12 +165,14 @@ public class MessageCommandService : IMessageCommandService
message.EditedAt = editParams.EditedAt; message.EditedAt = editParams.EditedAt;
await _messagesRepository.UpdateAsync(message); await _messagesRepository.UpdateAsync(message);
_logger.LogInformation("Message {MessageId} edited successfully by user {EditorId}", editParams.MessageId, editParams.EditorId); _logger.LogInformation("Message {MessageId} edited successfully by user {EditorId}", editParams.MessageId,
editParams.EditorId);
return new EditMessageResult(true, default, originalMessageForNotification); return new EditMessageResult(true, default, originalMessageForNotification);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error editing message {MessageId} by user {EditorId}", editParams.MessageId, editParams.EditorId); _logger.LogError(ex, "Error editing message {MessageId} by user {EditorId}", editParams.MessageId,
editParams.EditorId);
return new EditMessageResult(false, ex, default); return new EditMessageResult(false, ex, default);
} }
} }
@@ -221,7 +206,7 @@ public class MessageCommandService : IMessageCommandService
Id = message.Id, Id = message.Id,
SenderId = message.SenderId, SenderId = message.SenderId,
RecipientId = message.RecipientId, RecipientId = message.RecipientId,
RecipientType = message.RecipientType, RecipientType = message.RecipientType
}; };
await _messagesRepository.RemoveAsync(deleteParams.MessageId); await _messagesRepository.RemoveAsync(deleteParams.MessageId);
@@ -236,7 +221,8 @@ public class MessageCommandService : IMessageCommandService
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error deleting message {MessageId} by user {DeleterId}", deleteParams.MessageId, deleteParams.DeleterId); _logger.LogError(ex, "Error deleting message {MessageId} by user {DeleterId}", deleteParams.MessageId,
deleteParams.DeleterId);
return new DeleteMessageResult(false, ex, default); return new DeleteMessageResult(false, ex, default);
} }
} }
@@ -36,7 +36,6 @@ public class ProfileService : IProfileService
{ {
throw new NotFoundException("User's profile cant be found with given id", ex); throw new NotFoundException("User's profile cant be found with given id", ex);
} }
} }
public async Task SetDescription(string description, Guid userId) public async Task SetDescription(string description, Guid userId)
@@ -0,0 +1,107 @@
using FirebaseAdmin.Messaging;
using Govor.Application.Interfaces.PushNotifications;
using Govor.Application.Interfaces.PushNotifications.Models;
namespace Govor.Application.Services.PushNotifications.Providers;
public class FirebasePushProvider : IPushNotificationProvider
{
public string Name => "FCM";
public async Task<SendPushResult> SendToTokenAsync(string token, PushMessage message)
{
var msg = BuildFcmMessage(message, token: token);
try
{
string response = await FirebaseMessaging.DefaultInstance.SendAsync(msg);
return new SendPushResult(1, 0, []);
}
catch (FirebaseMessagingException ex)
{
if (IsInvalidTokenError(ex))
{
return new SendPushResult(0, 1, [token]);
}
throw; // return failed
}
}
public async Task<SendPushResult> SendMulticastAsync(IReadOnlyList<string> tokens, PushMessage message)
{
if (tokens.Count == 0)
return new SendPushResult(0, 0, []);
var multicastMsg = new MulticastMessage
{
Tokens = tokens.ToList(),
Notification = new Notification
{
Title = message.Title,
Body = message.Body
},
Data = message.Data,
Android = message.ChannelId != null ? new AndroidConfig
{
Notification = new AndroidNotification
{
ChannelId = message.ChannelId,
Priority = NotificationPriority.HIGH
}
} : null
};
try
{
var response = await FirebaseMessaging.DefaultInstance.SendEachForMulticastAsync(multicastMsg);
var failedTokens = new List<string>();
for (int i = 0; i < response.Responses.Count; i++)
{
if (!response.Responses[i].IsSuccess)
{
failedTokens.Add(tokens[i]);
}
}
return new SendPushResult(
response.SuccessCount,
response.FailureCount,
failedTokens
);
}
catch (Exception ex)
{
return new SendPushResult(0, tokens.Count, tokens.ToList());
}
}
private Message BuildFcmMessage(PushMessage pm, string? token = null)
{
var msg = new Message
{
Notification = new Notification { Title = pm.Title, Body = pm.Body },
Data = pm.Data,
Token = token
};
if (pm.ChannelId is not null)
{
msg.Android = new AndroidConfig
{
Notification = new AndroidNotification { ChannelId = pm.ChannelId }
};
}
return msg;
}
private static bool IsInvalidTokenError(FirebaseMessagingException ex)
{
return ex.MessagingErrorCode is MessagingErrorCode.Unregistered
or MessagingErrorCode.InvalidArgument
or MessagingErrorCode.SenderIdMismatch;
}
}
@@ -0,0 +1,19 @@
using Govor.Application.Interfaces.PushNotifications;
using Govor.Application.Interfaces.PushNotifications.Models;
namespace Govor.Application.Services.PushNotifications.Providers;
public class NullPushProvider : IPushNotificationProvider
{
public string Name => "NULL";
public Task<SendPushResult> SendToTokenAsync(string token, PushMessage message)
{
throw new NotImplementedException();
}
public Task<SendPushResult> SendMulticastAsync(IReadOnlyList<string> tokens, PushMessage message)
{
throw new NotImplementedException();
}
}
@@ -0,0 +1,95 @@
using FirebaseAdmin.Messaging;
using Govor.Application.Interfaces.PushNotifications;
using Govor.Application.Interfaces.PushNotifications.Models;
using Govor.Core.Repositories.PushTokens;
using Microsoft.Extensions.Logging;
namespace Govor.Application.Services.PushNotifications;
public class PushNotificationService : IPushNotificationService
{
private readonly IPushNotificationProvider _provider;
private readonly IPushTokenRepository _tokenRepo;
private readonly ILogger _logger;
public PushNotificationService(
IPushNotificationProvider provider,
IPushTokenRepository tokenRepo,
ILogger<PushNotificationService> logger)
{
_provider = provider;
_tokenRepo = tokenRepo;
_logger = logger;
}
public async Task SendToUserAsync(Guid userId, string title, string body, string channelId, Dictionary<string, string>? data = null)
{
var tokens = await _tokenRepo.GetActiveTokensAsync(userId);
if (tokens.Count == 0)
{
_logger.LogInformation("No active push tokens for user {UserId}", userId);
return;
}
var message = new PushMessage
{
Title = title,
Body = body,
Data = data ?? new(),
ChannelId = channelId
};
var result = await _provider.SendMulticastAsync(tokens, message);
if (result.FailureCount > 0)
{
await _tokenRepo.RemoveTokensAsync(result.FailedTokens);
_logger.LogWarning("Removed {Count} invalid FCM tokens for user {UserId}", result.FailureCount, userId);
}
}
public async Task SendToUsersAsync(IEnumerable<Guid> userIds, string title, string body, string channelId, Dictionary<string, string>? data = null)
{
if (userIds is null)
throw new ArgumentNullException(nameof(userIds));
var tokens = (await _tokenRepo.GetActiveTokensUsersAsync(userIds)).AsReadOnly();
if (tokens.Count == 0)
return;
var result = await _provider.SendMulticastAsync(tokens, new PushMessage()
{
Title = title,
Body = body,
Data = data ?? new(),
ChannelId = channelId
});
if (result.FailureCount > 0)
{
await _tokenRepo.RemoveTokensAsync(result.FailedTokens);
_logger.LogWarning("Removed {Count} invalid FCM tokens for users {Users}...", result.FailureCount, userIds);
}
}
public async Task SendToSessionAsync(Guid sessionId, string title, string body, string channelId, Dictionary<string, string>? data = null)
{
var token = await _tokenRepo.GetActiveTokenBySessionAsync(sessionId);
if(string.IsNullOrEmpty(token))
return;
var result = await _provider.SendToTokenAsync(token, new PushMessage()
{
Title = title,
Body = body,
Data = data ?? new(),
ChannelId = channelId
});
if (result.FailureCount > 0)
{
await _tokenRepo.RemoveTokensAsync(result.FailedTokens);
_logger.LogWarning("Removed {Count} invalid FCM tokens for session {Session}...", result.FailureCount, sessionId);
}
}
}
@@ -0,0 +1,46 @@
using Govor.Application.Interfaces;
using Govor.Core.Models;
using Govor.Core.Repositories.PrivateChats;
using Microsoft.Extensions.Logging;
namespace Govor.Application.Services;
public class UserPrivateChatsCreator : IUserPrivateChatsCreator
{
private readonly IPrivateChatsRepository _privateChats;
private readonly IPrivateChatGroupManager _privateChatGroupManager;
private readonly ILogger<UserPrivateChatsCreator> _logger;
public UserPrivateChatsCreator(
IPrivateChatsRepository privateChats,
IPrivateChatGroupManager privateChatGroupManager,
ILogger<UserPrivateChatsCreator> logger)
{
_privateChats = privateChats;
_privateChatGroupManager = privateChatGroupManager;
_logger = logger;
}
public async Task<PrivateChat> CreateAsync(Guid userIdA, Guid userIdB)
{
if (!_privateChats.Exist(userIdA, userIdB))
{
var recipientId = Guid.NewGuid();
var privateChat = new PrivateChat()
{
Id = recipientId,
UserAId = userIdA,
UserBId = userIdB
};
await _privateChats.AddAsync(privateChat);
await _privateChatGroupManager.AddUsersToPrivateChatGroupAsync(privateChat); // Notifying
_logger.LogInformation($"User for {userIdA} and {userIdB} was created private chat with Id: {recipientId}");
return privateChat;
}
_logger.LogInformation($"Private chat already exists for {userIdA} and {userIdB}; Returning already created chat...");
return await _privateChats.GetByMembersAsync(userIdA, userIdB);
}
}
@@ -1,4 +1,5 @@
using Govor.Application.Interfaces.UserSession; using Govor.Application.Interfaces.UserSession;
using Govor.Core.Repositories.PushTokens;
using Govor.Core.Repositories.UserSessionsRepository; using Govor.Core.Repositories.UserSessionsRepository;
using Govor.Data.Repositories.Exceptions; using Govor.Data.Repositories.Exceptions;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -8,11 +9,16 @@ namespace Govor.Application.Services.UserSessions;
public class UserSessionRevoker : IUserSessionRevoker public class UserSessionRevoker : IUserSessionRevoker
{ {
private readonly IUserSessionsRepository _sessionsRepository; private readonly IUserSessionsRepository _sessionsRepository;
private readonly IPushTokenRepository _pushTokenRepository;
private readonly ILogger<UserSessionRevoker> _logger; private readonly ILogger<UserSessionRevoker> _logger;
public UserSessionRevoker(IUserSessionsRepository sessionsRepository, ILogger<UserSessionRevoker> logger) public UserSessionRevoker(
IUserSessionsRepository sessionsRepository,
IPushTokenRepository pushTokenRepository,
ILogger<UserSessionRevoker> logger)
{ {
_sessionsRepository = sessionsRepository; _sessionsRepository = sessionsRepository;
_pushTokenRepository = pushTokenRepository;
_logger = logger; _logger = logger;
} }
@@ -32,6 +38,8 @@ public class UserSessionRevoker : IUserSessionRevoker
session.IsRevoked = true; session.IsRevoked = true;
await _sessionsRepository.UpdateAsync(session); await _sessionsRepository.UpdateAsync(session);
await _pushTokenRepository.DeactivateTokenBySessionAsync(sessionId); // pushes deactivate
} }
catch (NotFoundByKeyException<Guid> ex) catch (NotFoundByKeyException<Guid> ex)
{ {
@@ -52,6 +60,8 @@ public class UserSessionRevoker : IUserSessionRevoker
session.IsRevoked = true; session.IsRevoked = true;
await _sessionsRepository.UpdateAsync(session); await _sessionsRepository.UpdateAsync(session);
await _pushTokenRepository.DeactivateTokenBySessionAsync(session.Id); // pushes deactivate
} }
} }
catch (NotFoundByKeyException<Guid> ex) catch (NotFoundByKeyException<Guid> ex)
+7
View File
@@ -0,0 +1,7 @@
namespace Govor.Contracts.DTOs;
public class PrivateChatDto
{
public Guid ChatId { get; set; }
public Guid FriendId { get; set; }
}
@@ -0,0 +1,7 @@
namespace Govor.Contracts.Requests;
public class RegisterPushTokenRequest
{
public string Token { get; set; }
public string Platform { get; set; }
}
+20
View File
@@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore;
namespace Govor.Core.Models.Users;
public class UserPushToken
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
public Guid? UserSessionId { get; set; }
public string Token { get; set; } = string.Empty; // FCM/APNs
public string Provider { get; set; } = "FCM"; // FCM | APNs | Web | Huawei
public string Platform { get; set; } = string.Empty; // android | ios | web
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public DateTime? LastUsedAt { get; set; }
public bool IsActive { get; set; } = true;
}
@@ -0,0 +1,10 @@
namespace Govor.Core.Repositories.PushTokens;
public interface IPushTokenReader
{
Task<List<string>> GetActiveTokensAsync(Guid userId);
Task<List<string>> GetActiveTokensUsersAsync(IEnumerable<Guid> userIds);
Task<string> GetActiveTokenBySessionAsync(Guid sessionId);
}
@@ -0,0 +1,6 @@
namespace Govor.Core.Repositories.PushTokens;
public interface IPushTokenRepository : IPushTokenReader, IPushTokenWriter
{
}
@@ -0,0 +1,11 @@
namespace Govor.Core.Repositories.PushTokens;
public interface IPushTokenWriter
{
Task AddOrUpdateTokenAsync(Guid userId, Guid? sessionId, string token,
string platform, string provider = "FCM");
Task RemoveTokensAsync(IEnumerable<string> tokens);
Task DeactivateTokenBySessionAsync(Guid sessionId); // logout
}
@@ -0,0 +1,233 @@
using Govor.Core.Models.Users;
using Govor.Data.Repositories;
using Microsoft.EntityFrameworkCore;
namespace Govor.Data.Tests.Repositories;
[TestFixture]
public class PushTokenRepositoryTests
{
private GovorDbContext _context;
private PushTokenRepository _repository;
[SetUp]
public void Setup()
{
var options = new DbContextOptionsBuilder<GovorDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
_context = new GovorDbContext(options);
_repository = new PushTokenRepository(_context);
}
[TearDown]
public void TearDown()
{
_context.Dispose();
}
#region GetActiveTokensAsync
[Test]
public async Task GetActiveTokensAsync_ReturnsUserTokens()
{
var userId = Guid.NewGuid();
_context.UserPushTokens.Add(new UserPushToken
{
UserId = userId,
Token = "token1",
IsActive = true
});
await _context.SaveChangesAsync();
var result = await _repository.GetActiveTokensAsync(userId);
Assert.That(result, Contains.Item("token1"));
}
#endregion
#region GetActiveTokensUsersAsync
[Test]
public async Task GetActiveTokensUsersAsync_ReturnsDistinctActiveTokens()
{
var user1 = Guid.NewGuid();
var user2 = Guid.NewGuid();
_context.UserPushTokens.AddRange(
new UserPushToken { UserId = user1, Token = "t1", IsActive = true },
new UserPushToken { UserId = user2, Token = "t2", IsActive = true },
new UserPushToken { UserId = user2, Token = "inactive", IsActive = false }
);
await _context.SaveChangesAsync();
var result = await _repository.GetActiveTokensUsersAsync([user1, user2, user1]);
Assert.That(result.Count, Is.EqualTo(2));
Assert.That(result, Does.Not.Contain("inactive"));
}
[Test]
public async Task GetActiveTokensUsersAsync_EmptyInput_ReturnsEmpty()
{
var result = await _repository.GetActiveTokensUsersAsync([]);
Assert.That(result, Is.Empty);
}
#endregion
#region GetActiveTokenBySessionAsync
[Test]
public async Task GetActiveTokenBySessionAsync_ReturnsToken()
{
var sessionId = Guid.NewGuid();
_context.UserPushTokens.Add(new UserPushToken
{
UserSessionId = sessionId,
Token = "sessionToken",
IsActive = true
});
await _context.SaveChangesAsync();
var result = await _repository.GetActiveTokenBySessionAsync(sessionId);
Assert.That(result, Is.EqualTo("sessionToken"));
}
[Test]
public async Task GetActiveTokenBySessionAsync_EmptySession_ReturnsEmptyString()
{
var result = await _repository.GetActiveTokenBySessionAsync(Guid.Empty);
Assert.That(result, Is.EqualTo(""));
}
#endregion
#region AddOrUpdateTokenAsync
[Test]
public void AddOrUpdateTokenAsync_InvalidUserId_Throws()
{
Assert.ThrowsAsync<ArgumentException>(() =>
_repository.AddOrUpdateTokenAsync(Guid.Empty, null, "token", "android"));
}
[Test]
public void AddOrUpdateTokenAsync_EmptyToken_Throws()
{
Assert.ThrowsAsync<ArgumentException>(() =>
_repository.AddOrUpdateTokenAsync(Guid.NewGuid(), null, "", "android"));
}
[Test]
public async Task AddOrUpdateTokenAsync_AddsNewToken()
{
var userId = Guid.NewGuid();
await _repository.AddOrUpdateTokenAsync(userId, null, "newToken", "android");
var token = await _context.UserPushTokens.FirstOrDefaultAsync();
Assert.That(token, Is.Not.Null);
Assert.That(token!.UserId, Is.EqualTo(userId));
Assert.That(token.IsActive, Is.True);
}
[Test]
public async Task AddOrUpdateTokenAsync_UpdatesExistingToken()
{
var userId = Guid.NewGuid();
var existing = new UserPushToken
{
UserId = Guid.NewGuid(),
Token = "sameToken",
Platform = "ios",
IsActive = false
};
_context.UserPushTokens.Add(existing);
await _context.SaveChangesAsync();
await _repository.AddOrUpdateTokenAsync(userId, null, "sameToken", "android");
var updated = await _context.UserPushTokens.FirstAsync();
Assert.That(updated.UserId, Is.EqualTo(userId));
Assert.That(updated.Platform, Is.EqualTo("android"));
Assert.That(updated.IsActive, Is.True);
}
#endregion
#region RemoveTokensAsync
[Test]
public async Task RemoveTokensAsync_RemovesTokens()
{
_context.UserPushTokens.Add(new UserPushToken
{
Token = "t1",
UserId = Guid.NewGuid()
});
await _context.SaveChangesAsync();
await _repository.RemoveTokensAsync(["t1"]);
Assert.That(_context.UserPushTokens.Count(), Is.EqualTo(0));
}
[Test]
public async Task RemoveTokensAsync_EmptyInput_DoesNothing()
{
await _repository.RemoveTokensAsync([]);
Assert.Pass();
}
#endregion
#region DeactivateTokenBySessionAsync
[Test]
public async Task DeactivateTokenBySessionAsync_DeactivatesTokens()
{
var sessionId = Guid.NewGuid();
_context.UserPushTokens.Add(new UserPushToken
{
UserSessionId = sessionId,
Token = "t1",
IsActive = true
});
await _context.SaveChangesAsync();
await _repository.DeactivateTokenBySessionAsync(sessionId);
var token = await _context.UserPushTokens.FirstAsync();
Assert.That(token.IsActive, Is.False);
}
[Test]
public async Task DeactivateTokenBySessionAsync_EmptySession_DoesNothing()
{
await _repository.DeactivateTokenBySessionAsync(Guid.Empty);
Assert.Pass();
}
#endregion
}
@@ -10,6 +10,9 @@ public class MessagesConfiguration : IEntityTypeConfiguration<Message>
{ {
builder.HasKey(m => m.Id); builder.HasKey(m => m.Id);
// Просто индекс, без unique
builder.HasIndex(m => m.RecipientId);
builder.HasMany(m => m.Reactions) builder.HasMany(m => m.Reactions)
.WithOne(r => r.Message) .WithOne(r => r.Message)
.HasForeignKey(r => r.MessageId) .HasForeignKey(r => r.MessageId)
@@ -0,0 +1,13 @@
using Govor.Core.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Govor.Data.Configurations;
public class PrivateChatsConfiguration : IEntityTypeConfiguration<PrivateChat>
{
public void Configure(EntityTypeBuilder<PrivateChat> builder)
{
builder.HasKey(us => us.Id);
}
}
@@ -0,0 +1,18 @@
using Govor.Core.Models.Users;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Govor.Data.Configurations;
public class UserPushTokenConfiguration : IEntityTypeConfiguration<UserPushToken>
{
public void Configure(EntityTypeBuilder<UserPushToken> builder)
{
builder.HasKey(t => t.Id);
builder.HasIndex(x => x.Token).IsUnique();
builder.HasIndex(x => x.UserSessionId).IsUnique();
builder.HasIndex(x => x.UserId).IsUnique();
builder.HasIndex(x => new { x.UserId, x.IsActive });
}
}
@@ -12,6 +12,14 @@ public class UserSessionConfiguration : IEntityTypeConfiguration<UserSession>
{ {
builder.HasKey(us => us.Id); builder.HasKey(us => us.Id);
builder.HasIndex(us => us.Id)
.IsUnique();
builder.HasIndex(us => us.UserId);
builder.HasIndex(s => s.RefreshTokenHash)
.IsUnique();
builder.Property(us => us.RefreshTokenHash) builder.Property(us => us.RefreshTokenHash)
.IsRequired(); .IsRequired();
+3
View File
@@ -11,6 +11,7 @@ public class GovorDbContext(DbContextOptions<GovorDbContext> options) : DbContex
{ {
public virtual DbSet<User> Users { get; set; } public virtual DbSet<User> Users { get; set; }
public virtual DbSet<UserSession> UserSessions { get; set; } public virtual DbSet<UserSession> UserSessions { get; set; }
public virtual DbSet<UserPushToken> UserPushTokens { get; set; }
public virtual DbSet<UserCryptoSession> UserCryptoSessions { get; set; } public virtual DbSet<UserCryptoSession> UserCryptoSessions { get; set; }
public virtual DbSet<SignedPreKey> SignedPreKeys { get; set; } public virtual DbSet<SignedPreKey> SignedPreKeys { get; set; }
public virtual DbSet<OneTimePreKey> OneTimePreKeys { get; set; } public virtual DbSet<OneTimePreKey> OneTimePreKeys { get; set; }
@@ -51,6 +52,8 @@ public class GovorDbContext(DbContextOptions<GovorDbContext> options) : DbContex
modelBuilder.ApplyConfiguration(new OneTimePreKeyConfiguration()); modelBuilder.ApplyConfiguration(new OneTimePreKeyConfiguration());
modelBuilder.ApplyConfiguration(new UserCryptoSessionConfiguration()); modelBuilder.ApplyConfiguration(new UserCryptoSessionConfiguration());
modelBuilder.ApplyConfiguration(new SignedPreKeyConfiguration()); modelBuilder.ApplyConfiguration(new SignedPreKeyConfiguration());
modelBuilder.ApplyConfiguration(new PrivateChatsConfiguration());
modelBuilder.ApplyConfiguration(new UserPushTokenConfiguration());
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
} }
} }
@@ -1,763 +0,0 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<Guid>("ImageId")
.HasColumnType("uuid");
b.Property<bool>("IsChannel")
.HasColumnType("boolean");
b.Property<bool>("IsPrivate")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.ToTable("ChatGroups");
});
modelBuilder.Entity("Govor.Core.Models.Friendship", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("AddresseeId")
.HasColumnType("uuid");
b.Property<Guid>("RequesterId")
.HasColumnType("uuid");
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("AddresseeId");
b.HasIndex("RequesterId");
b.ToTable("Friendships");
});
modelBuilder.Entity("Govor.Core.Models.GroupAdmins", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("GroupId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("GroupId");
b.ToTable("GroupAdmins");
});
modelBuilder.Entity("Govor.Core.Models.GroupInvitation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTime>("EndDate")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("GroupId")
.HasColumnType("uuid");
b.Property<string>("InvitationCode")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("MaxParticipants")
.HasColumnType("integer");
b.Property<Guid>("UserMakerId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("GroupId");
b.HasIndex("UserMakerId");
b.ToTable("GroupInvitations");
});
modelBuilder.Entity("Govor.Core.Models.GroupMembership", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("GroupId")
.HasColumnType("uuid");
b.Property<Guid?>("InvitationId")
.HasColumnType("uuid");
b.Property<bool>("IsBanned")
.HasColumnType("boolean");
b.Property<DateTime>("MemberSince")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("GroupId");
b.HasIndex("InvitationId");
b.ToTable("GroupMemberships");
});
modelBuilder.Entity("Govor.Core.Models.Invitation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Code")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("EndDate")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<bool>("IsAdmin")
.HasColumnType("boolean");
b.Property<int>("MaxParticipants")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("Invitations");
});
modelBuilder.Entity("Govor.Core.Models.MediaFile", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<string>("MediaType")
.IsRequired()
.HasColumnType("text");
b.Property<string>("MineType")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("UploaderId")
.HasColumnType("uuid");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("MediaFiles");
});
modelBuilder.Entity("Govor.Core.Models.Messages.MediaAttachments", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("MediaFileId")
.HasColumnType("uuid");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("ChatGroupId")
.HasColumnType("uuid");
b.Property<DateTime?>("EditedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("EncryptedContent")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("IsEdited")
.HasColumnType("boolean");
b.Property<Guid?>("PrivateChatId")
.HasColumnType("uuid");
b.Property<Guid>("RecipientId")
.HasColumnType("uuid");
b.Property<int>("RecipientType")
.HasColumnType("integer");
b.Property<Guid?>("ReplyToMessageId")
.HasColumnType("uuid");
b.Property<Guid>("SenderId")
.HasColumnType("uuid");
b.Property<DateTime>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<DateTime>("ReactedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ReactionCode")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<DateTime>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("UserAId")
.HasColumnType("uuid");
b.Property<Guid>("UserBId")
.HasColumnType("uuid");
b.HasKey("Id");
b.ToTable("PrivateChats");
});
modelBuilder.Entity("Govor.Core.Models.Users.Admin", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("UserId");
b.ToTable("Admins");
});
modelBuilder.Entity("Govor.Core.Models.Users.Crypto.OneTimePreKey", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("IsUsed")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<byte[]>("PublicKey")
.IsRequired()
.HasColumnType("bytea");
b.Property<DateTime>("UploadedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<byte[]>("PublicSignedPreKey")
.IsRequired()
.HasColumnType("bytea");
b.Property<byte[]>("SignedPreKeySignature")
.IsRequired()
.HasColumnType("bytea");
b.Property<Guid>("UserCryptoSessionId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserCryptoSessionId")
.IsUnique();
b.ToTable("SignedPreKeys");
});
modelBuilder.Entity("Govor.Core.Models.Users.Crypto.UserCryptoSession", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<byte[]>("PublicIdentityKey")
.IsRequired()
.HasColumnType("bytea");
b.Property<Guid>("UserSessionId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserSessionId")
.IsUnique();
b.ToTable("UserCryptoSessions");
});
modelBuilder.Entity("Govor.Core.Models.Users.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateOnly>("CreatedOn")
.HasColumnType("date");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("IconId")
.HasColumnType("uuid");
b.Property<Guid>("InviteId")
.HasColumnType("uuid");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DeviceInfo")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsRevoked")
.HasColumnType("boolean");
b.Property<string>("RefreshToken")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("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
}
}
}
@@ -1,40 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Govor.Data.Migrations
{
/// <inheritdoc />
public partial class MediaOwnerTypeAdded : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "OwnerId",
table: "MediaFiles",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "OwnerType",
table: "MediaFiles",
type: "integer",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "OwnerId",
table: "MediaFiles");
migrationBuilder.DropColumn(
name: "OwnerType",
table: "MediaFiles");
}
}
}
@@ -1,94 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Govor.Data.Migrations
{
/// <inheritdoc />
public partial class update_sessions_model : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "RefreshToken",
table: "UserSessions",
newName: "RefreshTokenHash");
migrationBuilder.AlterColumn<string>(
name: "Username",
table: "Users",
type: "character varying(50)",
maxLength: 50,
nullable: false,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<string>(
name: "PasswordHash",
table: "Users",
type: "character varying(128)",
maxLength: 128,
nullable: false,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<string>(
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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Users_Username",
table: "Users");
migrationBuilder.RenameColumn(
name: "RefreshTokenHash",
table: "UserSessions",
newName: "RefreshToken");
migrationBuilder.AlterColumn<string>(
name: "Username",
table: "Users",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(50)",
oldMaxLength: 50);
migrationBuilder.AlterColumn<string>(
name: "PasswordHash",
table: "Users",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(128)",
oldMaxLength: 128);
migrationBuilder.AlterColumn<string>(
name: "Description",
table: "Users",
type: "text",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "character varying(500)",
oldMaxLength: 500,
oldNullable: true);
}
}
}
@@ -12,8 +12,8 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace Govor.Data.Migrations namespace Govor.Data.Migrations
{ {
[DbContext(typeof(GovorDbContext))] [DbContext(typeof(GovorDbContext))]
[Migration("20251103060801_MediaOwnerTypeAdded")] [Migration("20260301080331_Init")]
partial class MediaOwnerTypeAdded partial class Init
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -74,6 +74,9 @@ namespace Govor.Data.Migrations
b.HasIndex("AddresseeId"); b.HasIndex("AddresseeId");
b.HasIndex("Id")
.IsUnique();
b.HasIndex("RequesterId"); b.HasIndex("RequesterId");
b.ToTable("Friendships"); b.ToTable("Friendships");
@@ -300,8 +303,14 @@ namespace Govor.Data.Migrations
b.HasIndex("ChatGroupId"); b.HasIndex("ChatGroupId");
b.HasIndex("Id")
.IsUnique();
b.HasIndex("PrivateChatId"); b.HasIndex("PrivateChatId");
b.HasIndex("RecipientId")
.IsUnique();
b.ToTable("Messages"); b.ToTable("Messages");
}); });
@@ -372,6 +381,9 @@ namespace Govor.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("Id")
.IsUnique();
b.ToTable("PrivateChats"); b.ToTable("PrivateChats");
}); });
@@ -471,8 +483,8 @@ namespace Govor.Data.Migrations
.HasColumnType("date"); .HasColumnType("date");
b.Property<string>("Description") b.Property<string>("Description")
.IsRequired() .HasMaxLength(500)
.HasColumnType("text"); .HasColumnType("character varying(500)");
b.Property<Guid>("IconId") b.Property<Guid>("IconId")
.HasColumnType("uuid"); .HasColumnType("uuid");
@@ -482,22 +494,83 @@ namespace Govor.Data.Migrations
b.Property<string>("PasswordHash") b.Property<string>("PasswordHash")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Username") b.Property<string>("Username")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime>("WasOnline") b.Property<DateTime>("WasOnline")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("CreatedOn");
b.HasIndex("InviteId"); b.HasIndex("InviteId");
b.HasIndex("Username")
.IsUnique();
b.HasIndex("WasOnline");
b.ToTable("Users"); b.ToTable("Users");
}); });
modelBuilder.Entity("Govor.Core.Models.Users.UserPushToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<DateTime?>("LastUsedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Platform")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Provider")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Token")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<Guid?>("UserSessionId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Token")
.IsUnique();
b.HasIndex("UserId")
.IsUnique();
b.HasIndex("UserSessionId")
.IsUnique();
b.HasIndex("UserId", "IsActive");
b.ToTable("UserPushTokens");
});
modelBuilder.Entity("Govor.Core.Models.Users.UserSession", b => modelBuilder.Entity("Govor.Core.Models.Users.UserSession", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -518,7 +591,7 @@ namespace Govor.Data.Migrations
b.Property<bool>("IsRevoked") b.Property<bool>("IsRevoked")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<string>("RefreshToken") b.Property<string>("RefreshTokenHash")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
@@ -527,6 +600,12 @@ namespace Govor.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("Id")
.IsUnique();
b.HasIndex("RefreshTokenHash")
.IsUnique();
b.HasIndex("UserId"); b.HasIndex("UserId");
b.ToTable("UserSessions"); b.ToTable("UserSessions");
@@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Govor.Data.Migrations namespace Govor.Data.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class InitialCreate : Migration public partial class Init : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
@@ -54,7 +54,9 @@ namespace Govor.Data.Migrations
Url = table.Column<string>(type: "text", nullable: false), Url = table.Column<string>(type: "text", nullable: false),
MediaType = table.Column<string>(type: "text", nullable: false), MediaType = table.Column<string>(type: "text", nullable: false),
MineType = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false), MineType = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false) DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
OwnerType = table.Column<int>(type: "integer", nullable: false),
OwnerId = table.Column<Guid>(type: "uuid", nullable: true)
}, },
constraints: table => constraints: table =>
{ {
@@ -74,6 +76,26 @@ namespace Govor.Data.Migrations
table.PrimaryKey("PK_PrivateChats", x => x.Id); table.PrimaryKey("PK_PrivateChats", x => x.Id);
}); });
migrationBuilder.CreateTable(
name: "UserPushTokens",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
UserSessionId = table.Column<Guid>(type: "uuid", nullable: true),
Token = table.Column<string>(type: "text", nullable: false),
Provider = table.Column<string>(type: "text", nullable: false),
Platform = table.Column<string>(type: "text", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
LastUsedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
IsActive = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserPushTokens", x => x.Id);
});
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "GroupAdmins", name: "GroupAdmins",
columns: table => new columns: table => new
@@ -98,9 +120,9 @@ namespace Govor.Data.Migrations
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
Username = table.Column<string>(type: "text", nullable: false), Username = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
Description = table.Column<string>(type: "text", nullable: false), Description = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
PasswordHash = table.Column<string>(type: "text", nullable: false), PasswordHash = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
IconId = table.Column<Guid>(type: "uuid", nullable: false), IconId = table.Column<Guid>(type: "uuid", nullable: false),
CreatedOn = table.Column<DateOnly>(type: "date", nullable: false), CreatedOn = table.Column<DateOnly>(type: "date", nullable: false),
WasOnline = table.Column<DateTime>(type: "timestamp with time zone", nullable: false), WasOnline = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
@@ -227,7 +249,7 @@ namespace Govor.Data.Migrations
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false), UserId = table.Column<Guid>(type: "uuid", nullable: false),
RefreshToken = table.Column<string>(type: "text", nullable: false), RefreshTokenHash = table.Column<string>(type: "text", nullable: false),
DeviceInfo = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false), DeviceInfo = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false), CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false), ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
@@ -409,6 +431,12 @@ namespace Govor.Data.Migrations
table: "Friendships", table: "Friendships",
column: "AddresseeId"); column: "AddresseeId");
migrationBuilder.CreateIndex(
name: "IX_Friendships_Id",
table: "Friendships",
column: "Id",
unique: true);
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_Friendships_RequesterId", name: "IX_Friendships_RequesterId",
table: "Friendships", table: "Friendships",
@@ -465,11 +493,23 @@ namespace Govor.Data.Migrations
table: "Messages", table: "Messages",
column: "ChatGroupId"); column: "ChatGroupId");
migrationBuilder.CreateIndex(
name: "IX_Messages_Id",
table: "Messages",
column: "Id",
unique: true);
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_Messages_PrivateChatId", name: "IX_Messages_PrivateChatId",
table: "Messages", table: "Messages",
column: "PrivateChatId"); column: "PrivateChatId");
migrationBuilder.CreateIndex(
name: "IX_Messages_RecipientId",
table: "Messages",
column: "RecipientId",
unique: true);
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_MessageViews_MessageId_UserId", name: "IX_MessageViews_MessageId_UserId",
table: "MessageViews", table: "MessageViews",
@@ -486,6 +526,12 @@ namespace Govor.Data.Migrations
table: "OneTimePreKeys", table: "OneTimePreKeys",
columns: new[] { "UserCryptoSessionId", "IsUsed" }); columns: new[] { "UserCryptoSessionId", "IsUsed" });
migrationBuilder.CreateIndex(
name: "IX_PrivateChats_Id",
table: "PrivateChats",
column: "Id",
unique: true);
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_SignedPreKeys_UserCryptoSessionId", name: "IX_SignedPreKeys_UserCryptoSessionId",
table: "SignedPreKeys", table: "SignedPreKeys",
@@ -498,11 +544,62 @@ namespace Govor.Data.Migrations
column: "UserSessionId", column: "UserSessionId",
unique: true); unique: true);
migrationBuilder.CreateIndex(
name: "IX_UserPushTokens_Token",
table: "UserPushTokens",
column: "Token",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_UserPushTokens_UserId",
table: "UserPushTokens",
column: "UserId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_UserPushTokens_UserId_IsActive",
table: "UserPushTokens",
columns: new[] { "UserId", "IsActive" });
migrationBuilder.CreateIndex(
name: "IX_UserPushTokens_UserSessionId",
table: "UserPushTokens",
column: "UserSessionId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Users_CreatedOn",
table: "Users",
column: "CreatedOn");
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_Users_InviteId", name: "IX_Users_InviteId",
table: "Users", table: "Users",
column: "InviteId"); column: "InviteId");
migrationBuilder.CreateIndex(
name: "IX_Users_Username",
table: "Users",
column: "Username",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Users_WasOnline",
table: "Users",
column: "WasOnline");
migrationBuilder.CreateIndex(
name: "IX_UserSessions_Id",
table: "UserSessions",
column: "Id",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_UserSessions_RefreshTokenHash",
table: "UserSessions",
column: "RefreshTokenHash",
unique: true);
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_UserSessions_UserId", name: "IX_UserSessions_UserId",
table: "UserSessions", table: "UserSessions",
@@ -539,6 +636,9 @@ namespace Govor.Data.Migrations
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "SignedPreKeys"); name: "SignedPreKeys");
migrationBuilder.DropTable(
name: "UserPushTokens");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "GroupInvitations"); name: "GroupInvitations");
@@ -12,8 +12,8 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace Govor.Data.Migrations namespace Govor.Data.Migrations
{ {
[DbContext(typeof(GovorDbContext))] [DbContext(typeof(GovorDbContext))]
[Migration("20251214065852_update_sessions_model")] [Migration("20260301091506_FixRecipientIndex")]
partial class update_sessions_model partial class FixRecipientIndex
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -302,6 +302,8 @@ namespace Govor.Data.Migrations
b.HasIndex("PrivateChatId"); b.HasIndex("PrivateChatId");
b.HasIndex("RecipientId");
b.ToTable("Messages"); b.ToTable("Messages");
}); });
@@ -495,14 +497,70 @@ namespace Govor.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("CreatedOn");
b.HasIndex("InviteId"); b.HasIndex("InviteId");
b.HasIndex("Username") b.HasIndex("Username")
.IsUnique(); .IsUnique();
b.HasIndex("WasOnline");
b.ToTable("Users"); b.ToTable("Users");
}); });
modelBuilder.Entity("Govor.Core.Models.Users.UserPushToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<DateTime?>("LastUsedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Platform")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Provider")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Token")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<Guid?>("UserSessionId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Token")
.IsUnique();
b.HasIndex("UserId")
.IsUnique();
b.HasIndex("UserSessionId")
.IsUnique();
b.HasIndex("UserId", "IsActive");
b.ToTable("UserPushTokens");
});
modelBuilder.Entity("Govor.Core.Models.Users.UserSession", b => modelBuilder.Entity("Govor.Core.Models.Users.UserSession", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -532,6 +590,12 @@ namespace Govor.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("Id")
.IsUnique();
b.HasIndex("RefreshTokenHash")
.IsUnique();
b.HasIndex("UserId"); b.HasIndex("UserId");
b.ToTable("UserSessions"); b.ToTable("UserSessions");
@@ -0,0 +1,67 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Govor.Data.Migrations
{
/// <inheritdoc />
public partial class FixRecipientIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_PrivateChats_Id",
table: "PrivateChats");
migrationBuilder.DropIndex(
name: "IX_Messages_Id",
table: "Messages");
migrationBuilder.DropIndex(
name: "IX_Messages_RecipientId",
table: "Messages");
migrationBuilder.DropIndex(
name: "IX_Friendships_Id",
table: "Friendships");
migrationBuilder.CreateIndex(
name: "IX_Messages_RecipientId",
table: "Messages",
column: "RecipientId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Messages_RecipientId",
table: "Messages");
migrationBuilder.CreateIndex(
name: "IX_PrivateChats_Id",
table: "PrivateChats",
column: "Id",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Messages_Id",
table: "Messages",
column: "Id",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Messages_RecipientId",
table: "Messages",
column: "RecipientId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Friendships_Id",
table: "Friendships",
column: "Id",
unique: true);
}
}
}
@@ -299,6 +299,8 @@ namespace Govor.Data.Migrations
b.HasIndex("PrivateChatId"); b.HasIndex("PrivateChatId");
b.HasIndex("RecipientId");
b.ToTable("Messages"); b.ToTable("Messages");
}); });
@@ -492,14 +494,70 @@ namespace Govor.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("CreatedOn");
b.HasIndex("InviteId"); b.HasIndex("InviteId");
b.HasIndex("Username") b.HasIndex("Username")
.IsUnique(); .IsUnique();
b.HasIndex("WasOnline");
b.ToTable("Users"); b.ToTable("Users");
}); });
modelBuilder.Entity("Govor.Core.Models.Users.UserPushToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<DateTime?>("LastUsedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Platform")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Provider")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Token")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<Guid?>("UserSessionId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Token")
.IsUnique();
b.HasIndex("UserId")
.IsUnique();
b.HasIndex("UserSessionId")
.IsUnique();
b.HasIndex("UserId", "IsActive");
b.ToTable("UserPushTokens");
});
modelBuilder.Entity("Govor.Core.Models.Users.UserSession", b => modelBuilder.Entity("Govor.Core.Models.Users.UserSession", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -529,6 +587,12 @@ namespace Govor.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("Id")
.IsUnique();
b.HasIndex("RefreshTokenHash")
.IsUnique();
b.HasIndex("UserId"); b.HasIndex("UserId");
b.ToTable("UserSessions"); b.ToTable("UserSessions");
@@ -0,0 +1,140 @@
using Govor.Core.Infrastructure.Extensions;
using Govor.Core.Models.Users;
using Govor.Core.Repositories.PushTokens;
using Govor.Data.Repositories.Exceptions;
using Microsoft.EntityFrameworkCore;
namespace Govor.Data.Repositories;
public class PushTokenRepository : IPushTokenRepository
{
private GovorDbContext _context;
public PushTokenRepository(GovorDbContext context)
{
_context = context;
}
public async Task<List<string>> GetActiveTokensAsync(Guid userId)
{
return await _context.UserPushTokens
.AsNoTracking()
.Where(t => t.UserId == userId && t.IsActive)
.Select(t => t.Token)
.ToListAsync();
}
public async Task<List<string>> GetActiveTokensUsersAsync(IEnumerable<Guid> userIds)
{
if (userIds is null || !userIds.Any())
return [];
var distinctIds = userIds.Distinct().ToList();
return await _context.UserPushTokens
.AsNoTracking()
.Where(t => distinctIds.Contains(t.UserId) && t.IsActive)
.Select(t => t.Token)
.ToListAsync();
}
public async Task<string> GetActiveTokenBySessionAsync(Guid sessionId)
{
if (sessionId == Guid.Empty)
return "";
return await _context.UserPushTokens
.AsNoTracking()
.Where(t => t.UserSessionId == sessionId && t.IsActive)
.Select(t => t.Token)
.FirstOrDefaultAsync() ?? "";
}
public async Task AddOrUpdateTokenAsync(Guid userId, Guid? sessionId, string token, string platform, string provider = "FCM")
{
if (userId == Guid.Empty)
throw new ArgumentException("UserId cannot be empty", nameof(userId));
if (string.IsNullOrWhiteSpace(token))
throw new ArgumentException("Token cannot be empty", nameof(token));
if (string.IsNullOrWhiteSpace(platform))
throw new ArgumentException("Platform cannot be empty", nameof(platform));
var existing = await _context.UserPushTokens
.FirstOrDefaultAsync(t => t.Token == token);
if (existing != null)
{
// Updates
existing.UserId = userId;
existing.UserSessionId = sessionId;
existing.Platform = platform;
existing.Provider = provider;
existing.UpdatedAt = DateTime.UtcNow;
existing.LastUsedAt = DateTime.UtcNow;
existing.IsActive = true;
_context.UserPushTokens.Update(existing);
}
else
{
// Add new
var newToken = new UserPushToken
{
UserId = userId,
UserSessionId = sessionId,
Token = token,
Platform = platform,
Provider = provider,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
LastUsedAt = DateTime.UtcNow,
IsActive = true,
};
_context.UserPushTokens.Add(newToken);
}
await _context.SaveChangesAsync();
}
public async Task RemoveTokensAsync(IEnumerable<string> tokens)
{
if (tokens is null || !tokens.Any())
return;
var tokenList = tokens.Distinct().ToList();
var toRemove = await _context.UserPushTokens
.Where(t => tokenList.Contains(t.Token))
.ToListAsync();
if (toRemove.Any())
{
_context.UserPushTokens.RemoveRange(toRemove);
await _context.SaveChangesAsync();
}
}
public async Task DeactivateTokenBySessionAsync(Guid sessionId)
{
if (sessionId == Guid.Empty)
return;
var tokens = await _context.UserPushTokens
.Where(t => t.UserSessionId == sessionId && t.IsActive)
.ToListAsync();
if (tokens.Any())
{
foreach (var token in tokens)
{
token.IsActive = false;
token.UpdatedAt = DateTime.UtcNow;
}
await _context.SaveChangesAsync();
}
}
}