From b24649e53a717add16f7bbee91b32a8b4514a36f Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Wed, 23 Jul 2025 13:06:34 +0700 Subject: [PATCH] Refactor message loading API and add message query support Reworked message loading endpoints and services to support flexible message querying with 'before' and 'after' parameters via a new MessageQuery contract. Updated controller actions, service interfaces, and implementations to use the new query model, and improved error handling. Added integration tests for ChatLoadController and introduced IUserPresenceService interface. Minor fixes and help text improvements in console client commands. --- .../Controllers/ChatLoadControllerTests.cs | 107 +++++++++++++ Govor.API/Controllers/ChatLoadController.cs | 55 +++++-- .../Controllers/OnlinePingingController.cs | 17 +- .../Services/Messages/MessagesLoaderTests.cs | 20 +-- .../Interfaces/IMessagesLoader.cs | 4 +- .../Interfaces/IUserPresenceService.cs | 6 + .../Services/Messages/MessagesLoader.cs | 148 +++++++++++++----- Govor.ConsoleClient/Commands/HelpCommand.cs | 14 +- .../Commands/SendMessageCommand.cs | 2 +- Govor.Contracts/Requests/MessageQuery.cs | 8 + 10 files changed, 300 insertions(+), 81 deletions(-) create mode 100644 Govor.API.Tests/IntegrationTests/Controllers/ChatLoadControllerTests.cs create mode 100644 Govor.Application/Interfaces/IUserPresenceService.cs create mode 100644 Govor.Contracts/Requests/MessageQuery.cs diff --git a/Govor.API.Tests/IntegrationTests/Controllers/ChatLoadControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/ChatLoadControllerTests.cs new file mode 100644 index 0000000..00a0377 --- /dev/null +++ b/Govor.API.Tests/IntegrationTests/Controllers/ChatLoadControllerTests.cs @@ -0,0 +1,107 @@ +using AutoFixture; +using AutoMapper; +using Govor.API.Controllers; +using Govor.Application.Interfaces; +using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.Contracts.Requests; +using Govor.Contracts.Responses; +using Govor.Core.Models.Messages; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Govor.API.Tests.IntegrationTests.Controllers; + +[TestFixture] +public class ChatLoadControllerTests +{ + private Mock _currentUserServiceMock; + private Mock> _loggerMock; + private Mock _messagesLoaderMock; + private Mock _mapperMock; + private Fixture _fixture; + private ChatLoadController _controller; + + private Guid _userId = Guid.NewGuid(); + private Guid _chatId = Guid.NewGuid(); + private Guid _currentId = Guid.NewGuid(); + + + [SetUp] + public void SetUp() + { + _fixture = new Fixture(); + _fixture.Behaviors.OfType().ToList().ForEach(b => _fixture.Behaviors.Remove(b)); + _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); + + _currentUserServiceMock = new Mock(); + _loggerMock = new Mock>(); + _messagesLoaderMock = new Mock(); + _mapperMock = new Mock(); + + _currentUserServiceMock.Setup(u => u.GetCurrentUserId()).Returns(_currentId); + + _controller = new ChatLoadController( + _loggerMock.Object, + _messagesLoaderMock.Object, + _currentUserServiceMock.Object, + _mapperMock.Object); + } + + // Tests for GetChatMessages action + [Test] + public async Task GetChatMessages_ValidRequest_ReturnsOkResult() + { + // Arrange + var random = new Random(); + var messages = _fixture.CreateMany(random.Next(3, 10)).ToList(); + var before = random.Next(1, 10); + var after = random.Next(1, 10); + + var query = new MessageQuery() + { + Before = before, + After = after, + StartMessageId = null + }; + + _messagesLoaderMock.Setup(m => + m.LoadMessagesInChatGroup(_chatId, _currentId, null, before, after)) + .ReturnsAsync(messages); + + var messagesResponse = messages.Select(m => new MessageResponse() + { + Id = m.Id, + SenderId = m.SenderId, + RecipientId = m.RecipientId, + RecipientType = m.RecipientType, + }).ToList(); + + _mapperMock.Setup(f => f.Map>(messages)). + Returns(messagesResponse); + + // Act + var result = await _controller.GetGroupMessages(_chatId, query); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result, Is.InstanceOf()); + + var okResult = (OkObjectResult)result; + var values = okResult.Value as List; + + Assert.That(values, Is.Not.Null); + + Assert.That(values.Select(v => v.Id), + Is.EquivalentTo(messagesResponse.Select(m => m.Id))); + + Assert.That(values.Select(v => v.SenderId), + Is.EquivalentTo(messagesResponse.Select(m => m.SenderId))); + + Assert.That(values.Select(v => v.RecipientId), + Is.EquivalentTo(messagesResponse.Select(m => m.RecipientId))); + + Assert.That(values.Select(v => v.RecipientType), + Is.EquivalentTo(messagesResponse.Select(m => m.RecipientType))); + } +} \ No newline at end of file diff --git a/Govor.API/Controllers/ChatLoadController.cs b/Govor.API/Controllers/ChatLoadController.cs index 36fd42d..fcda9ca 100644 --- a/Govor.API/Controllers/ChatLoadController.cs +++ b/Govor.API/Controllers/ChatLoadController.cs @@ -1,7 +1,9 @@ using AutoMapper; using Govor.Application.Interfaces; using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.Contracts.Requests; using Govor.Contracts.Responses; +using Govor.Data.Repositories.Exceptions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -16,6 +18,7 @@ public class ChatLoadController : Controller private readonly ILogger _logger; private readonly IMessagesLoader _messagesLoader; private readonly IMapper _mapper; + public ChatLoadController( ILogger logger, IMessagesLoader messagesLoader, @@ -29,18 +32,21 @@ public class ChatLoadController : Controller } [HttpGet("group-messages")] - public async Task GetChatMessages( - [FromQuery] Guid chatId, - [FromQuery] Guid? startMessageId, - [FromQuery] int pageSize) + public async Task GetGroupMessages( + Guid chatId, + [FromQuery] MessageQuery query) { try { - var result = await _messagesLoader.LoadLastMessagesInChatGroup( + if (query.Before < 0 || query.After < 0 || query.After + query.Before > 100) + return BadRequest("Values must be non-negative and total must not exceed 100."); + + var result = await _messagesLoader.LoadMessagesInChatGroup( chatId, _currentUser.GetCurrentUserId(), - startMessageId, - pageSize); + query.StartMessageId, + query.Before, + query.After); var response = _mapper.Map>(result); @@ -51,6 +57,16 @@ public class ChatLoadController : Controller _logger.LogWarning(ex.Message); return Forbid(ex.Message); } + catch (NotFoundException ex) + { + _logger.LogWarning(ex, ex.Message); + return BadRequest(ex.Message); + } + catch (ArgumentException ex) + { + _logger.LogWarning(ex, ex.Message); + return BadRequest(ex.Message); + } catch (Exception ex) { _logger.LogError(ex, ex.Message); @@ -60,17 +76,20 @@ public class ChatLoadController : Controller [HttpGet("user-messages")] public async Task GetUserMessages( - [FromQuery] Guid userId, - [FromQuery] Guid? startMessageId, - [FromQuery] int pageSize = 20) + Guid userId, + [FromQuery] MessageQuery query) { try { - var result = await _messagesLoader.LoadLastMessagesInUserChat( + if (query.Before < 0 || query.After < 0 || query.After + query.Before > 100) + return BadRequest("Values must be non-negative and total must not exceed 100."); + + var result = await _messagesLoader.LoadMessagesInUserChat( userId, _currentUser.GetCurrentUserId(), - startMessageId, - pageSize); + query.StartMessageId, + query.Before, + query.After); var response = _mapper.Map>(result); @@ -86,6 +105,16 @@ public class ChatLoadController : Controller _logger.LogWarning(ex.Message); return Forbid(ex.Message); } + catch (NotFoundException ex) + { + _logger.LogWarning(ex, ex.Message); + return BadRequest(ex.Message); + } + catch (ArgumentException ex) + { + _logger.LogWarning(ex, ex.Message); + return BadRequest(ex.Message); + } catch (Exception ex) { _logger.LogError(ex, ex.Message); diff --git a/Govor.API/Controllers/OnlinePingingController.cs b/Govor.API/Controllers/OnlinePingingController.cs index 871e960..3f3a026 100644 --- a/Govor.API/Controllers/OnlinePingingController.cs +++ b/Govor.API/Controllers/OnlinePingingController.cs @@ -13,6 +13,7 @@ public class OnlinePingingController : Controller { private readonly ILogger _logger; private readonly IPingHandlerService _ping; + private readonly IUserPresenceService _presenceService; private readonly ICurrentUserService _currentUserService; public OnlinePingingController(ILogger logger, @@ -51,13 +52,21 @@ public class OnlinePingingController : Controller catch (Exception e) { _logger.LogError(e, e.Message); - return StatusCode(500, new { error = "Failed to send friend request." }); + return StatusCode(500, "Failed to ping."); } } - [HttpGet("is-online")] - public async Task IsOnline(Guid userId) + [HttpGet("status/{userId}")] + public IActionResult GetStatus(Guid userId) { - return BadRequest(); + try + { + return Ok(_presenceService.WhenUserWasOnline(userId)); + } + catch (Exception e) + { + _logger.LogError(e, e.Message); + return StatusCode(500, "Internal server error."); + } } } \ No newline at end of file diff --git a/Govor.Application.Tests/Services/Messages/MessagesLoaderTests.cs b/Govor.Application.Tests/Services/Messages/MessagesLoaderTests.cs index 3c8f76d..4bdf983 100644 --- a/Govor.Application.Tests/Services/Messages/MessagesLoaderTests.cs +++ b/Govor.Application.Tests/Services/Messages/MessagesLoaderTests.cs @@ -56,7 +56,7 @@ public class MessagesLoaderTests await _dbContext.SaveChangesAsync(); // Act - var result = await _loader.LoadLastMessagesInUserChat(_currentUserId, _otherUserId, null); + var result = await _loader.LoadMessagesInUserChat(_currentUserId, _otherUserId, null); // Assert Assert.That(result, Has.Count.EqualTo(1)); @@ -70,7 +70,7 @@ public class MessagesLoaderTests _privateChatsRepoMock.Setup(r => r.GetByMembersAsync(_currentUserId, _otherUserId)) .ReturnsAsync(new PrivateChat { Id = Guid.NewGuid() }); // Act - var result = await _loader.LoadLastMessagesInUserChat(_currentUserId, _otherUserId, null); + var result = await _loader.LoadMessagesInUserChat(_currentUserId, _otherUserId, null); // Assert Assert.That(result, Is.Empty); @@ -81,7 +81,7 @@ public class MessagesLoaderTests { // Act & Assert var ex = Assert.ThrowsAsync(async () => - await _loader.LoadLastMessagesInUserChat(Guid.Empty, _currentUserId, null)); + await _loader.LoadMessagesInUserChat(Guid.Empty, _currentUserId, null)); Assert.That(ex.Message, Does.Contain("User id cannot be empty")); } @@ -94,7 +94,7 @@ public class MessagesLoaderTests // Act & Assert var ex = Assert.ThrowsAsync(async () => - await _loader.LoadLastMessagesInUserChat(_currentUserId, _otherUserId, null)); + await _loader.LoadMessagesInUserChat(_currentUserId, _otherUserId, null)); Assert.That(ex.Message, Is.EqualTo("Private chat not found")); } @@ -116,7 +116,7 @@ public class MessagesLoaderTests await _dbContext.SaveChangesAsync(); // Act - var result = await _loader.LoadLastMessagesInChatGroup(_groupChatId, _currentUserId, null); + var result = await _loader.LoadMessagesInChatGroup(_groupChatId, _currentUserId, null); // Assert Assert.That(result, Has.Count.EqualTo(1)); @@ -127,9 +127,9 @@ public class MessagesLoaderTests { // Act & Assert var ex = Assert.ThrowsAsync(async () => - await _loader.LoadLastMessagesInChatGroup(Guid.Empty, _currentUserId, null)); + await _loader.LoadMessagesInChatGroup(Guid.Empty, _currentUserId, null)); - Assert.That(ex.Message, Does.Contain("Chat id cannot be empty")); + Assert.That(ex.Message, Is.EqualTo("Chat id cannot be empty")); } [Test] @@ -141,9 +141,9 @@ public class MessagesLoaderTests // Act & Assert var ex = Assert.ThrowsAsync(async () => - await _loader.LoadLastMessagesInChatGroup(_groupChatId, _currentUserId, null)); + await _loader.LoadMessagesInChatGroup(_groupChatId, _currentUserId, null)); - Assert.That(ex.Message, Is.EqualTo("You are not in a group.")); + Assert.That(ex.Message, Is.EqualTo("You are not a member of this group.")); } [Test] @@ -154,7 +154,7 @@ public class MessagesLoaderTests .ReturnsAsync(true); // Act - var result = await _loader.LoadLastMessagesInChatGroup(_groupChatId, _currentUserId, null); + var result = await _loader.LoadMessagesInChatGroup(_groupChatId, _currentUserId, null); // Assert Assert.That(result, Is.Empty); diff --git a/Govor.Application/Interfaces/IMessagesLoader.cs b/Govor.Application/Interfaces/IMessagesLoader.cs index cb8880d..54fff2f 100644 --- a/Govor.Application/Interfaces/IMessagesLoader.cs +++ b/Govor.Application/Interfaces/IMessagesLoader.cs @@ -4,6 +4,6 @@ namespace Govor.Application.Interfaces; public interface IMessagesLoader { - Task> LoadLastMessagesInUserChat(Guid userId,Guid currentId, Guid? startMessageId, int pageSize = 20); - Task> LoadLastMessagesInChatGroup(Guid chatId,Guid currentId, Guid? startMessageId, int pageSize = 20); + Task> LoadMessagesInUserChat(Guid userId,Guid currentId, Guid? startMessageId, int before = 20, int after = 2); + Task> LoadMessagesInChatGroup(Guid chatId,Guid currentId, Guid? startMessageId, int before = 20, int after = 2); } \ No newline at end of file diff --git a/Govor.Application/Interfaces/IUserPresenceService.cs b/Govor.Application/Interfaces/IUserPresenceService.cs new file mode 100644 index 0000000..38eea19 --- /dev/null +++ b/Govor.Application/Interfaces/IUserPresenceService.cs @@ -0,0 +1,6 @@ +namespace Govor.Application.Interfaces; + +public interface IUserPresenceService +{ + DateTime WhenUserWasOnline(Guid userId); +} \ No newline at end of file diff --git a/Govor.Application/Services/Messages/MessagesLoader.cs b/Govor.Application/Services/Messages/MessagesLoader.cs index 541a400..872084b 100644 --- a/Govor.Application/Services/Messages/MessagesLoader.cs +++ b/Govor.Application/Services/Messages/MessagesLoader.cs @@ -1,8 +1,6 @@ using Govor.Application.Interfaces; -using Govor.Core.Infrastructure.Extensions; using Govor.Core.Models.Messages; using Govor.Core.Repositories.Groups; -using Govor.Core.Repositories.Messages; using Govor.Core.Repositories.PrivateChats; using Govor.Data; using Govor.Data.Repositories.Exceptions; @@ -26,57 +24,121 @@ public class MessagesLoader : IMessagesLoader _dbContext = dbContext; } - public async Task> LoadLastMessagesInUserChat(Guid userId, Guid currentUser, Guid? startMessageId, int pageSize = 20) + public async Task> LoadMessagesInUserChat( + Guid userId, + Guid currentUser, + Guid? startMessageId, + int before = 20, + int after = 2) { - if(userId == Guid.Empty) + if (userId == Guid.Empty) throw new ArgumentException("User id cannot be empty"); - - if(!_privateChatsRepository.Exist(userId, currentUser)) + + if (!_privateChatsRepository.Exist(userId, currentUser)) throw new InvalidOperationException("Private chat not found"); - - try + + var chat = await _privateChatsRepository.GetByMembersAsync(userId, currentUser); + + var query = _dbContext.Messages + .AsNoTracking() + .Include(m => m.MediaAttachments) + .ThenInclude(m => m.MediaFile) + .Where(m => m.RecipientType == RecipientType.User && + m.RecipientId == chat.Id); + + if (startMessageId is null) { - var chat = await _privateChatsRepository.GetByMembersAsync(userId, currentUser); - - return await _dbContext.Messages - .AsNoTracking() - .Include(m => m.MediaAttachments) - .ThenInclude(m => m.MediaFile) - .AsSplitQuery() - .Where(m => m.RecipientType == RecipientType.User && - m.RecipientId == chat.Id) - .Take(pageSize) - .ToListOrThrowIfEmpty(new NotFoundException("Messages not found")); - } - catch (NotFoundException ex) - { - return new List(); + return await query + .OrderByDescending(m => m.SentAt) + .Take(before) + .ToListAsync(); } + + var startMessage = await _dbContext.Messages.FindAsync(startMessageId.Value); + if (startMessage == null) + throw new NotFoundException("Start message not found"); + + var beforeMessages = await query + .Where(m => m.SentAt < startMessage.SentAt) + .OrderByDescending(m => m.SentAt) + .Take(before) + .ToListAsync(); + + var afterMessages = await query + .Where(m => m.SentAt > startMessage.SentAt) + .OrderBy(m => m.SentAt) + .Take(after) + .ToListAsync(); + + // older -> start -> newer + var result = beforeMessages + .OrderBy(m => m.SentAt) + .Concat(new[] { startMessage }) + .Concat(afterMessages) + .ToList(); + + return result; } - public async Task> LoadLastMessagesInChatGroup(Guid chatId, Guid currentUser, Guid? startMessageId, int pageSize = 20) + + public async Task> LoadMessagesInChatGroup( + Guid chatId, + Guid currentUser, + Guid? startMessageId, + int before = 20, + int after = 2) { - if(chatId == Guid.Empty) + if (chatId == Guid.Empty) throw new ArgumentException("Chat id cannot be empty"); - - if(!await _groupsRepository.IsUserMemberOfGroupAsync(currentUser, chatId)) - throw new UnauthorizedAccessException("You are not in a group."); - - try + + var isMember = await _groupsRepository.IsUserMemberOfGroupAsync(currentUser, chatId); + if (!isMember) + throw new UnauthorizedAccessException("You are not a member of this group."); + + var baseQuery = _dbContext.Messages + .AsNoTracking() + .Include(m => m.MediaAttachments) + .ThenInclude(m => m.MediaFile) + .AsSplitQuery() + .Where(m => m.RecipientType == RecipientType.Group && m.RecipientId == chatId); + + if (startMessageId is null) { - return await _dbContext.Messages - .AsNoTracking() - .Include(m => m.MediaAttachments) - .ThenInclude(m => m.MediaFile) - .AsSplitQuery() - .Where(m => m.RecipientType == RecipientType.Group && - m.RecipientId == chatId) - .Take(pageSize) - .ToListOrThrowIfEmpty(new NotFoundException("Messages not found")); - } - catch (NotFoundException ex) - { - return new List(); + return await baseQuery + .OrderByDescending(m => m.SentAt) + .Take(before) + .OrderBy(m => m.SentAt) + .ToListAsync(); } + + var startMessage = await _dbContext.Messages + .AsNoTracking() + .FirstOrDefaultAsync(m => m.Id == startMessageId.Value && + m.RecipientType == RecipientType.Group && + m.RecipientId == chatId); + + if (startMessage == null) + throw new NotFoundException("Start message not found in this group."); + + var beforeMessages = await baseQuery + .Where(m => m.SentAt < startMessage.SentAt) + .OrderByDescending(m => m.SentAt) + .Take(before) + .ToListAsync(); + + var afterMessages = await baseQuery + .Where(m => m.SentAt > startMessage.SentAt) + .OrderBy(m => m.SentAt) + .Take(after) + .ToListAsync(); + + var result = beforeMessages + .OrderBy(m => m.SentAt) + .Concat(new[] { startMessage }) + .Concat(afterMessages) + .ToList(); + + return result; } + } \ No newline at end of file diff --git a/Govor.ConsoleClient/Commands/HelpCommand.cs b/Govor.ConsoleClient/Commands/HelpCommand.cs index b8cd16f..f0fa612 100644 --- a/Govor.ConsoleClient/Commands/HelpCommand.cs +++ b/Govor.ConsoleClient/Commands/HelpCommand.cs @@ -39,16 +39,14 @@ public class HelpCommand : ICommand else { _logger.Info("Чтобы получить подробную информацию, напишите /help {command}"); - } - - foreach (var command in commands) - { - var name = command.GetType().GetCustomAttribute()?.Path.Replace("/", "").ToLower() - ?? command.GetType().Name.Replace("Command", "").ToLower(); + foreach (var command in commands) + { + var name = command.GetType().GetCustomAttribute()?.Path.Replace("/", "").ToLower() + ?? command.GetType().Name.Replace("Command", "").ToLower(); - _logger.Log($"{name} - {command.ShortHelp()}"); + _logger.Log($"{name} - {command.ShortHelp()}"); + } } - return Task.CompletedTask; } diff --git a/Govor.ConsoleClient/Commands/SendMessageCommand.cs b/Govor.ConsoleClient/Commands/SendMessageCommand.cs index 8aa96db..56d4528 100644 --- a/Govor.ConsoleClient/Commands/SendMessageCommand.cs +++ b/Govor.ConsoleClient/Commands/SendMessageCommand.cs @@ -34,7 +34,7 @@ public class SendMessageCommand : IInteractiveCommand public string LongHelp() { - throw new NotImplementedException(); + return "Отпарвка тестовых сообщений не существующему юзеру 2"; } public string ShortHelp() diff --git a/Govor.Contracts/Requests/MessageQuery.cs b/Govor.Contracts/Requests/MessageQuery.cs new file mode 100644 index 0000000..5dd24f7 --- /dev/null +++ b/Govor.Contracts/Requests/MessageQuery.cs @@ -0,0 +1,8 @@ +namespace Govor.Contracts.Requests; + +public class MessageQuery +{ + public Guid? StartMessageId { get; set; } + public int Before { get; set; } = 20; + public int After { get; set; } = 2; +}