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.
This commit is contained in:
Artemy
2025-07-23 13:06:34 +07:00
parent 1fc13000b1
commit b24649e53a
10 changed files with 300 additions and 81 deletions
@@ -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<ICurrentUserService> _currentUserServiceMock;
private Mock<ILogger<ChatLoadController>> _loggerMock;
private Mock<IMessagesLoader> _messagesLoaderMock;
private Mock<IMapper> _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<ThrowingRecursionBehavior>().ToList().ForEach(b => _fixture.Behaviors.Remove(b));
_fixture.Behaviors.Add(new OmitOnRecursionBehavior());
_currentUserServiceMock = new Mock<ICurrentUserService>();
_loggerMock = new Mock<ILogger<ChatLoadController>>();
_messagesLoaderMock = new Mock<IMessagesLoader>();
_mapperMock = new Mock<IMapper>();
_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<Message>(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<List<MessageResponse>>(messages)).
Returns(messagesResponse);
// Act
var result = await _controller.GetGroupMessages(_chatId, query);
// Assert
Assert.That(result, Is.Not.Null);
Assert.That(result, Is.InstanceOf<OkObjectResult>());
var okResult = (OkObjectResult)result;
var values = okResult.Value as List<MessageResponse>;
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)));
}
}
+42 -13
View File
@@ -1,7 +1,9 @@
using AutoMapper; using AutoMapper;
using Govor.Application.Interfaces; using Govor.Application.Interfaces;
using Govor.Application.Interfaces.Infrastructure.Extensions; using Govor.Application.Interfaces.Infrastructure.Extensions;
using Govor.Contracts.Requests;
using Govor.Contracts.Responses; using Govor.Contracts.Responses;
using Govor.Data.Repositories.Exceptions;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -16,6 +18,7 @@ public class ChatLoadController : Controller
private readonly ILogger<ChatLoadController> _logger; private readonly ILogger<ChatLoadController> _logger;
private readonly IMessagesLoader _messagesLoader; private readonly IMessagesLoader _messagesLoader;
private readonly IMapper _mapper; private readonly IMapper _mapper;
public ChatLoadController( public ChatLoadController(
ILogger<ChatLoadController> logger, ILogger<ChatLoadController> logger,
IMessagesLoader messagesLoader, IMessagesLoader messagesLoader,
@@ -29,18 +32,21 @@ public class ChatLoadController : Controller
} }
[HttpGet("group-messages")] [HttpGet("group-messages")]
public async Task<IActionResult> GetChatMessages( public async Task<IActionResult> GetGroupMessages(
[FromQuery] Guid chatId, Guid chatId,
[FromQuery] Guid? startMessageId, [FromQuery] MessageQuery query)
[FromQuery] int pageSize)
{ {
try 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, chatId,
_currentUser.GetCurrentUserId(), _currentUser.GetCurrentUserId(),
startMessageId, query.StartMessageId,
pageSize); query.Before,
query.After);
var response = _mapper.Map<List<MessageResponse>>(result); var response = _mapper.Map<List<MessageResponse>>(result);
@@ -51,6 +57,16 @@ public class ChatLoadController : Controller
_logger.LogWarning(ex.Message); _logger.LogWarning(ex.Message);
return Forbid(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) catch (Exception ex)
{ {
_logger.LogError(ex, ex.Message); _logger.LogError(ex, ex.Message);
@@ -60,17 +76,20 @@ public class ChatLoadController : Controller
[HttpGet("user-messages")] [HttpGet("user-messages")]
public async Task<IActionResult> GetUserMessages( public async Task<IActionResult> GetUserMessages(
[FromQuery] Guid userId, Guid userId,
[FromQuery] Guid? startMessageId, [FromQuery] MessageQuery query)
[FromQuery] int pageSize = 20)
{ {
try 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, userId,
_currentUser.GetCurrentUserId(), _currentUser.GetCurrentUserId(),
startMessageId, query.StartMessageId,
pageSize); query.Before,
query.After);
var response = _mapper.Map<List<MessageResponse>>(result); var response = _mapper.Map<List<MessageResponse>>(result);
@@ -86,6 +105,16 @@ public class ChatLoadController : Controller
_logger.LogWarning(ex.Message); _logger.LogWarning(ex.Message);
return Forbid(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) catch (Exception ex)
{ {
_logger.LogError(ex, ex.Message); _logger.LogError(ex, ex.Message);
@@ -13,6 +13,7 @@ public class OnlinePingingController : Controller
{ {
private readonly ILogger<OnlinePingingController> _logger; private readonly ILogger<OnlinePingingController> _logger;
private readonly IPingHandlerService _ping; private readonly IPingHandlerService _ping;
private readonly IUserPresenceService _presenceService;
private readonly ICurrentUserService _currentUserService; private readonly ICurrentUserService _currentUserService;
public OnlinePingingController(ILogger<OnlinePingingController> logger, public OnlinePingingController(ILogger<OnlinePingingController> logger,
@@ -51,13 +52,21 @@ public class OnlinePingingController : Controller
catch (Exception e) catch (Exception e)
{ {
_logger.LogError(e, e.Message); _logger.LogError(e, e.Message);
return StatusCode(500, new { error = "Failed to send friend request." }); return StatusCode(500, "Failed to ping.");
} }
} }
[HttpGet("is-online")] [HttpGet("status/{userId}")]
public async Task<IActionResult> IsOnline(Guid 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.");
}
} }
} }
@@ -56,7 +56,7 @@ public class MessagesLoaderTests
await _dbContext.SaveChangesAsync(); await _dbContext.SaveChangesAsync();
// Act // Act
var result = await _loader.LoadLastMessagesInUserChat(_currentUserId, _otherUserId, null); var result = await _loader.LoadMessagesInUserChat(_currentUserId, _otherUserId, null);
// Assert // Assert
Assert.That(result, Has.Count.EqualTo(1)); Assert.That(result, Has.Count.EqualTo(1));
@@ -70,7 +70,7 @@ public class MessagesLoaderTests
_privateChatsRepoMock.Setup(r => r.GetByMembersAsync(_currentUserId, _otherUserId)) _privateChatsRepoMock.Setup(r => r.GetByMembersAsync(_currentUserId, _otherUserId))
.ReturnsAsync(new PrivateChat { Id = Guid.NewGuid() }); .ReturnsAsync(new PrivateChat { Id = Guid.NewGuid() });
// Act // Act
var result = await _loader.LoadLastMessagesInUserChat(_currentUserId, _otherUserId, null); var result = await _loader.LoadMessagesInUserChat(_currentUserId, _otherUserId, null);
// Assert // Assert
Assert.That(result, Is.Empty); Assert.That(result, Is.Empty);
@@ -81,7 +81,7 @@ public class MessagesLoaderTests
{ {
// Act & Assert // Act & Assert
var ex = Assert.ThrowsAsync<ArgumentException>(async () => var ex = Assert.ThrowsAsync<ArgumentException>(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")); Assert.That(ex.Message, Does.Contain("User id cannot be empty"));
} }
@@ -94,7 +94,7 @@ public class MessagesLoaderTests
// Act & Assert // Act & Assert
var ex = Assert.ThrowsAsync<InvalidOperationException>(async () => var ex = Assert.ThrowsAsync<InvalidOperationException>(async () =>
await _loader.LoadLastMessagesInUserChat(_currentUserId, _otherUserId, null)); await _loader.LoadMessagesInUserChat(_currentUserId, _otherUserId, null));
Assert.That(ex.Message, Is.EqualTo("Private chat not found")); Assert.That(ex.Message, Is.EqualTo("Private chat not found"));
} }
@@ -116,7 +116,7 @@ public class MessagesLoaderTests
await _dbContext.SaveChangesAsync(); await _dbContext.SaveChangesAsync();
// Act // Act
var result = await _loader.LoadLastMessagesInChatGroup(_groupChatId, _currentUserId, null); var result = await _loader.LoadMessagesInChatGroup(_groupChatId, _currentUserId, null);
// Assert // Assert
Assert.That(result, Has.Count.EqualTo(1)); Assert.That(result, Has.Count.EqualTo(1));
@@ -127,9 +127,9 @@ public class MessagesLoaderTests
{ {
// Act & Assert // Act & Assert
var ex = Assert.ThrowsAsync<ArgumentException>(async () => var ex = Assert.ThrowsAsync<ArgumentException>(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] [Test]
@@ -141,9 +141,9 @@ public class MessagesLoaderTests
// Act & Assert // Act & Assert
var ex = Assert.ThrowsAsync<UnauthorizedAccessException>(async () => var ex = Assert.ThrowsAsync<UnauthorizedAccessException>(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] [Test]
@@ -154,7 +154,7 @@ public class MessagesLoaderTests
.ReturnsAsync(true); .ReturnsAsync(true);
// Act // Act
var result = await _loader.LoadLastMessagesInChatGroup(_groupChatId, _currentUserId, null); var result = await _loader.LoadMessagesInChatGroup(_groupChatId, _currentUserId, null);
// Assert // Assert
Assert.That(result, Is.Empty); Assert.That(result, Is.Empty);
@@ -4,6 +4,6 @@ namespace Govor.Application.Interfaces;
public interface IMessagesLoader public interface IMessagesLoader
{ {
Task<List<Message>> LoadLastMessagesInUserChat(Guid userId,Guid currentId, Guid? startMessageId, int pageSize = 20); Task<List<Message>> LoadMessagesInUserChat(Guid userId,Guid currentId, Guid? startMessageId, int before = 20, int after = 2);
Task<List<Message>> LoadLastMessagesInChatGroup(Guid chatId,Guid currentId, Guid? startMessageId, int pageSize = 20); Task<List<Message>> LoadMessagesInChatGroup(Guid chatId,Guid currentId, Guid? startMessageId, int before = 20, int after = 2);
} }
@@ -0,0 +1,6 @@
namespace Govor.Application.Interfaces;
public interface IUserPresenceService
{
DateTime WhenUserWasOnline(Guid userId);
}
@@ -1,8 +1,6 @@
using Govor.Application.Interfaces; using Govor.Application.Interfaces;
using Govor.Core.Infrastructure.Extensions;
using Govor.Core.Models.Messages; using Govor.Core.Models.Messages;
using Govor.Core.Repositories.Groups; using Govor.Core.Repositories.Groups;
using Govor.Core.Repositories.Messages;
using Govor.Core.Repositories.PrivateChats; using Govor.Core.Repositories.PrivateChats;
using Govor.Data; using Govor.Data;
using Govor.Data.Repositories.Exceptions; using Govor.Data.Repositories.Exceptions;
@@ -26,7 +24,12 @@ public class MessagesLoader : IMessagesLoader
_dbContext = dbContext; _dbContext = dbContext;
} }
public async Task<List<Message>> LoadLastMessagesInUserChat(Guid userId, Guid currentUser, Guid? startMessageId, int pageSize = 20) public async Task<List<Message>> 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"); throw new ArgumentException("User id cannot be empty");
@@ -34,49 +37,108 @@ public class MessagesLoader : IMessagesLoader
if (!_privateChatsRepository.Exist(userId, currentUser)) if (!_privateChatsRepository.Exist(userId, currentUser))
throw new InvalidOperationException("Private chat not found"); throw new InvalidOperationException("Private chat not found");
try
{
var chat = await _privateChatsRepository.GetByMembersAsync(userId, currentUser); var chat = await _privateChatsRepository.GetByMembersAsync(userId, currentUser);
return await _dbContext.Messages var query = _dbContext.Messages
.AsNoTracking() .AsNoTracking()
.Include(m => m.MediaAttachments) .Include(m => m.MediaAttachments)
.ThenInclude(m => m.MediaFile) .ThenInclude(m => m.MediaFile)
.AsSplitQuery()
.Where(m => m.RecipientType == RecipientType.User && .Where(m => m.RecipientType == RecipientType.User &&
m.RecipientId == chat.Id) m.RecipientId == chat.Id);
.Take(pageSize)
.ToListOrThrowIfEmpty(new NotFoundException("Messages not found")); if (startMessageId is null)
}
catch (NotFoundException ex)
{ {
return new List<Message>(); return await query
} .OrderByDescending(m => m.SentAt)
.Take(before)
.ToListAsync();
} }
public async Task<List<Message>> LoadLastMessagesInChatGroup(Guid chatId, Guid currentUser, Guid? startMessageId, int pageSize = 20) 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<List<Message>> 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"); throw new ArgumentException("Chat id cannot be empty");
if(!await _groupsRepository.IsUserMemberOfGroupAsync(currentUser, chatId)) var isMember = await _groupsRepository.IsUserMemberOfGroupAsync(currentUser, chatId);
throw new UnauthorizedAccessException("You are not in a group."); if (!isMember)
throw new UnauthorizedAccessException("You are not a member of this group.");
try var baseQuery = _dbContext.Messages
{
return await _dbContext.Messages
.AsNoTracking() .AsNoTracking()
.Include(m => m.MediaAttachments) .Include(m => m.MediaAttachments)
.ThenInclude(m => m.MediaFile) .ThenInclude(m => m.MediaFile)
.AsSplitQuery() .AsSplitQuery()
.Where(m => m.RecipientType == RecipientType.Group && .Where(m => m.RecipientType == RecipientType.Group && m.RecipientId == chatId);
m.RecipientId == chatId)
.Take(pageSize) if (startMessageId is null)
.ToListOrThrowIfEmpty(new NotFoundException("Messages not found"));
}
catch (NotFoundException ex)
{ {
return new List<Message>(); 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;
} }
} }
+1 -3
View File
@@ -39,8 +39,6 @@ public class HelpCommand : ICommand
else else
{ {
_logger.Info("Чтобы получить подробную информацию, напишите /help {command}"); _logger.Info("Чтобы получить подробную информацию, напишите /help {command}");
}
foreach (var command in commands) foreach (var command in commands)
{ {
var name = command.GetType().GetCustomAttribute<CommandRouteAttribute>()?.Path.Replace("/", "").ToLower() var name = command.GetType().GetCustomAttribute<CommandRouteAttribute>()?.Path.Replace("/", "").ToLower()
@@ -48,7 +46,7 @@ public class HelpCommand : ICommand
_logger.Log($"{name} - {command.ShortHelp()}"); _logger.Log($"{name} - {command.ShortHelp()}");
} }
}
return Task.CompletedTask; return Task.CompletedTask;
} }
@@ -34,7 +34,7 @@ public class SendMessageCommand : IInteractiveCommand
public string LongHelp() public string LongHelp()
{ {
throw new NotImplementedException(); return "Отпарвка тестовых сообщений не существующему юзеру 2";
} }
public string ShortHelp() public string ShortHelp()
+8
View File
@@ -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;
}