diff --git a/ShoppingAssistantApi.Api/Controllers/BaseController.cs b/ShoppingAssistantApi.Api/Controllers/BaseController.cs new file mode 100644 index 0000000..87efa2c --- /dev/null +++ b/ShoppingAssistantApi.Api/Controllers/BaseController.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Mvc; + +namespace ShoppingAssistantApi.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class BaseController : ControllerBase +{ + +} \ No newline at end of file diff --git a/ShoppingAssistantApi.Api/Controllers/ProductsSearchController.cs b/ShoppingAssistantApi.Api/Controllers/ProductsSearchController.cs new file mode 100644 index 0000000..7dd9949 --- /dev/null +++ b/ShoppingAssistantApi.Api/Controllers/ProductsSearchController.cs @@ -0,0 +1,39 @@ +using HotChocolate.Authorization; +using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json; +using ShoppingAssistantApi.Application.IServices; +using ShoppingAssistantApi.Application.Models.CreateDtos; + +namespace ShoppingAssistantApi.Api.Controllers; + +[Authorize] +public class ProductsSearchController : BaseController +{ + private readonly IProductService _productService; + + public ProductsSearchController(IProductService productService) + { + _productService = productService; + } + + [HttpPost("search/{wishlistId}")] + public async Task StreamDataToClient(string wishlistId, [FromBody]MessageCreateDto message, CancellationToken cancellationToken) + { + Response.Headers.Add("Content-Type", "text/event-stream"); + Response.Headers.Add("Cache-Control", "no-cache"); + Response.Headers.Add("Connection", "keep-alive"); + + var result = _productService.SearchProductAsync(wishlistId, message, cancellationToken); + + await foreach (var sse in result) + { + var chunk = JsonConvert.SerializeObject(sse.Data); + + var serverSentEvent = $"event: {sse.Event}\ndata: {chunk}\n\n"; + + await Response.WriteAsync(serverSentEvent); + await Response.Body.FlushAsync(); + } + } + +} \ No newline at end of file diff --git a/ShoppingAssistantApi.Api/ShoppingAssistantApi.Api.csproj b/ShoppingAssistantApi.Api/ShoppingAssistantApi.Api.csproj index 50dd57a..761a282 100644 --- a/ShoppingAssistantApi.Api/ShoppingAssistantApi.Api.csproj +++ b/ShoppingAssistantApi.Api/ShoppingAssistantApi.Api.csproj @@ -21,8 +21,5 @@ - - - diff --git a/ShoppingAssistantApi.Application/IServices/IProductService.cs b/ShoppingAssistantApi.Application/IServices/IProductService.cs new file mode 100644 index 0000000..3cf6d42 --- /dev/null +++ b/ShoppingAssistantApi.Application/IServices/IProductService.cs @@ -0,0 +1,12 @@ +using ShoppingAssistantApi.Application.Models.CreateDtos; +using ShoppingAssistantApi.Application.Models.Dtos; +using ShoppingAssistantApi.Application.Models.ProductSearch; +using ShoppingAssistantApi.Domain.Entities; + +namespace ShoppingAssistantApi.Application.IServices; + +public interface IProductService +{ + IAsyncEnumerable SearchProductAsync(string wishlistId, MessageCreateDto message, CancellationToken cancellationToken); + +} \ No newline at end of file diff --git a/ShoppingAssistantApi.Application/Models/OpenAi/ChatCompletionRequest.cs b/ShoppingAssistantApi.Application/Models/OpenAi/ChatCompletionRequest.cs index d2ad66b..40f3972 100644 --- a/ShoppingAssistantApi.Application/Models/OpenAi/ChatCompletionRequest.cs +++ b/ShoppingAssistantApi.Application/Models/OpenAi/ChatCompletionRequest.cs @@ -2,7 +2,7 @@ namespace ShoppingAssistantApi.Application.Models.OpenAi; public class ChatCompletionRequest { - public string Model { get; set; } = "gpt-3.5-turbo"; + public string Model { get; set; } = "gpt-4"; public List Messages { get; set; } diff --git a/ShoppingAssistantApi.Application/Models/ProductSearch/MessagePart.cs b/ShoppingAssistantApi.Application/Models/ProductSearch/MessagePart.cs new file mode 100644 index 0000000..feacc20 --- /dev/null +++ b/ShoppingAssistantApi.Application/Models/ProductSearch/MessagePart.cs @@ -0,0 +1,6 @@ +namespace ShoppingAssistantApi.Application.Models.ProductSearch; + +public class MessagePart +{ + public string Text { get; set; } +} diff --git a/ShoppingAssistantApi.Application/Models/ProductSearch/ProductName.cs b/ShoppingAssistantApi.Application/Models/ProductSearch/ProductName.cs new file mode 100644 index 0000000..559ab3d --- /dev/null +++ b/ShoppingAssistantApi.Application/Models/ProductSearch/ProductName.cs @@ -0,0 +1,6 @@ +namespace ShoppingAssistantApi.Application.Models.ProductSearch; + +public class ProductName +{ + public string Name { get; set; } +} \ No newline at end of file diff --git a/ShoppingAssistantApi.Application/Models/ProductSearch/Question.cs b/ShoppingAssistantApi.Application/Models/ProductSearch/Question.cs new file mode 100644 index 0000000..e1f0c46 --- /dev/null +++ b/ShoppingAssistantApi.Application/Models/ProductSearch/Question.cs @@ -0,0 +1,6 @@ +namespace ShoppingAssistantApi.Application.Models.ProductSearch; + +public class Question +{ + public string QuestionText { get; set; } +} \ No newline at end of file diff --git a/ShoppingAssistantApi.Application/Models/ProductSearch/ServerSentEvent.cs b/ShoppingAssistantApi.Application/Models/ProductSearch/ServerSentEvent.cs new file mode 100644 index 0000000..e7be1cb --- /dev/null +++ b/ShoppingAssistantApi.Application/Models/ProductSearch/ServerSentEvent.cs @@ -0,0 +1,10 @@ +using ShoppingAssistantApi.Domain.Enums; + +namespace ShoppingAssistantApi.Application.Models.ProductSearch; + +public class ServerSentEvent +{ + public SearchEventType Event { get; set; } + + public string Data { get; set; } +} diff --git a/ShoppingAssistantApi.Application/Models/ProductSearch/Suggestion.cs b/ShoppingAssistantApi.Application/Models/ProductSearch/Suggestion.cs new file mode 100644 index 0000000..ccefea2 --- /dev/null +++ b/ShoppingAssistantApi.Application/Models/ProductSearch/Suggestion.cs @@ -0,0 +1,6 @@ +namespace ShoppingAssistantApi.Application.Models.ProductSearch; + +public class Suggestion +{ + public string Text { get; set; } +} diff --git a/ShoppingAssistantApi.Application/ShoppingAssistantApi.Application.csproj b/ShoppingAssistantApi.Application/ShoppingAssistantApi.Application.csproj index 46083d4..e7b7e9f 100644 --- a/ShoppingAssistantApi.Application/ShoppingAssistantApi.Application.csproj +++ b/ShoppingAssistantApi.Application/ShoppingAssistantApi.Application.csproj @@ -16,4 +16,5 @@ + diff --git a/ShoppingAssistantApi.Domain/Entities/Wishlist.cs b/ShoppingAssistantApi.Domain/Entities/Wishlist.cs index 4dee7fc..9c9bfc6 100644 --- a/ShoppingAssistantApi.Domain/Entities/Wishlist.cs +++ b/ShoppingAssistantApi.Domain/Entities/Wishlist.cs @@ -7,6 +7,4 @@ public class Wishlist : EntityBase public string Name { get; set; } public string Type { get; set; } - - public ICollection? Messages { get; set; } } diff --git a/ShoppingAssistantApi.Domain/Enums/SearchEventType.cs b/ShoppingAssistantApi.Domain/Enums/SearchEventType.cs new file mode 100644 index 0000000..3388692 --- /dev/null +++ b/ShoppingAssistantApi.Domain/Enums/SearchEventType.cs @@ -0,0 +1,24 @@ +namespace ShoppingAssistantApi.Domain.Enums; + +public enum SearchEventType +{ + Wishlist = 0, + Message = 1, + Suggestion = 2, + Product = 3 +} + +public static class SearchEventTypeExtensions +{ + public static string ToSseEventString(this SearchEventType eventType) + { + return eventType switch + { + SearchEventType.Wishlist => "wishlist", + SearchEventType.Message => "message", + SearchEventType.Suggestion => "suggestion", + SearchEventType.Product => "product", + _ => throw new ArgumentOutOfRangeException(nameof(eventType), eventType, null), + }; + } +} diff --git a/ShoppingAssistantApi.Infrastructure/InfrastructureExtentions/ServicesExtention.cs b/ShoppingAssistantApi.Infrastructure/InfrastructureExtentions/ServicesExtention.cs index 32aa136..e3df82c 100644 --- a/ShoppingAssistantApi.Infrastructure/InfrastructureExtentions/ServicesExtention.cs +++ b/ShoppingAssistantApi.Infrastructure/InfrastructureExtentions/ServicesExtention.cs @@ -18,6 +18,7 @@ public static class ServicesExtention services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/ShoppingAssistantApi.Infrastructure/Services/ProductService.cs b/ShoppingAssistantApi.Infrastructure/Services/ProductService.cs new file mode 100644 index 0000000..8c4d567 --- /dev/null +++ b/ShoppingAssistantApi.Infrastructure/Services/ProductService.cs @@ -0,0 +1,191 @@ +using System.Diagnostics; +using MongoDB.Bson; +using ShoppingAssistantApi.Application.IRepositories; +using ShoppingAssistantApi.Application.IServices; +using ShoppingAssistantApi.Application.Models.CreateDtos; +using ShoppingAssistantApi.Application.Models.Dtos; +using ShoppingAssistantApi.Application.Models.OpenAi; +using ShoppingAssistantApi.Application.Models.ProductSearch; +using ShoppingAssistantApi.Domain.Entities; +using ShoppingAssistantApi.Domain.Enums; +using ServerSentEvent = ShoppingAssistantApi.Application.Models.ProductSearch.ServerSentEvent; + +namespace ShoppingAssistantApi.Infrastructure.Services; + +public class ProductService : IProductService +{ + private readonly IWishlistsService _wishlistsService; + + private readonly IOpenAiService _openAiService; + + private readonly IMessagesRepository _messagesRepository; + + public ProductService(IOpenAiService openAiService, IWishlistsService wishlistsService, IMessagesRepository messagesRepository) + { + _openAiService = openAiService; + _wishlistsService = wishlistsService; + _messagesRepository = messagesRepository; + } + + public async IAsyncEnumerable SearchProductAsync(string wishlistId, MessageCreateDto message, CancellationToken cancellationToken) + { + string promptForGpt = + "You are a Shopping Assistant that helps people find product recommendations. Ask user additional questions if more context needed." + + "\nYou must return data with one of the prefixes:" + + "\n[Question] - return question" + + "\n[Suggestions] - return semicolon separated suggestion how to answer to a question" + + "\n[Message] - return text" + + "\n[Products] - return semicolon separated product names"; + + var countOfMessage = await _messagesRepository + .GetCountAsync(message=>message.WishlistId==ObjectId.Parse((wishlistId)), cancellationToken); + + var previousMessages = await _wishlistsService + .GetMessagesPageFromPersonalWishlistAsync(wishlistId, 1, countOfMessage, cancellationToken); + + var chatRequest = new ChatCompletionRequest + { + Messages = new List + { + new OpenAiMessage + { + Role = OpenAiRoleExtensions.RequestConvert(OpenAiRole.System), + Content = promptForGpt + } + } + }; + + + var messagesForOpenAI = new List(); + + foreach (var item in previousMessages.Items) + { + messagesForOpenAI + .Add(new OpenAiMessage() + { + Role = item.Role.ToLower(), + Content = item.Text + }); + } + + messagesForOpenAI.Add(new OpenAiMessage() + { + Role = OpenAiRoleExtensions.RequestConvert(OpenAiRole.User), + Content = message.Text + }); + + chatRequest.Messages.AddRange(messagesForOpenAI); + + var suggestionBuffer = new Suggestion(); + var messageBuffer = new MessagePart(); + var productBuffer = new ProductName(); + var currentDataType = SearchEventType.Wishlist; + var dataTypeHolder = string.Empty; + + await foreach (var data in _openAiService.GetChatCompletionStream(chatRequest, cancellationToken)) + { + if (data.Contains("[")) + { + if (dataTypeHolder=="[Message]" && messageBuffer.Text!=null) + { + _wishlistsService.AddMessageToPersonalWishlistAsync(wishlistId, new MessageCreateDto() + { + Text = messageBuffer.Text, + }, cancellationToken); + } + dataTypeHolder = string.Empty; + dataTypeHolder += data; + } + + else if (data.Contains("]")) + { + dataTypeHolder += data; + currentDataType = DetermineDataType(dataTypeHolder); + } + + else if (dataTypeHolder=="[" && !data.Contains("[")) + { + dataTypeHolder += data; + } + + else + { + switch (currentDataType) + { + case SearchEventType.Message: + yield return new ServerSentEvent + { + Event = SearchEventType.Message, + Data = data + }; + messageBuffer.Text += data; + break; + + case SearchEventType.Suggestion: + if (data.Contains(";")) + { + yield return new ServerSentEvent + { + Event = SearchEventType.Suggestion, + Data = suggestionBuffer.Text + }; + suggestionBuffer.Text = string.Empty; + break; + } + suggestionBuffer.Text += data; + break; + + case SearchEventType.Product: + if (data.Contains(";")) + { + yield return new ServerSentEvent + { + Event = SearchEventType.Product, + Data = productBuffer.Name + }; + productBuffer.Name = string.Empty; + + //a complete description of the entity when the Amazon API is connected + await _wishlistsService.AddProductToPersonalWishlistAsync(wishlistId, new ProductCreateDto() + { + Url = "", + Name = productBuffer.Name, + Rating = 0, + Description = "", + ImagesUrls = new []{"", ""}, + WasOpened = false + }, cancellationToken); + break; + } + productBuffer.Name += data; + break; + } + } + } + } + + private SearchEventType DetermineDataType(string dataTypeHolder) + { + if (dataTypeHolder.StartsWith("[Question]")) + { + return SearchEventType.Message; + } + else if (dataTypeHolder.StartsWith("[Options]")) + { + return SearchEventType.Suggestion; + } + else if (dataTypeHolder.StartsWith("[Message]")) + { + return SearchEventType.Message; + } + else if (dataTypeHolder.StartsWith("[Products]")) + { + return SearchEventType.Product; + } + else + { + return SearchEventType.Wishlist; + } + } + +} \ No newline at end of file diff --git a/ShoppingAssistantApi.Persistance/PersistanceExtentions/DbInitialaizer.cs b/ShoppingAssistantApi.Persistance/PersistanceExtentions/DbInitialaizer.cs index f627b46..98425a6 100644 --- a/ShoppingAssistantApi.Persistance/PersistanceExtentions/DbInitialaizer.cs +++ b/ShoppingAssistantApi.Persistance/PersistanceExtentions/DbInitialaizer.cs @@ -190,25 +190,6 @@ public class DbInitialaizer Name = "Gaming PC", Type = WishlistTypes.Product.ToString(), CreatedById = user1.Id, - Messages = new Message[] - { - new Message - { - Text = "Prompt", - Role = MessageRoles.User.ToString(), - WishlistId = ObjectId.Parse("ab79cde6f69abcd3efab65cd"), - CreatedById = user1.Id, - CreatedDateUtc = DateTime.UtcNow - }, - new Message - { - Text = "Answer", - Role = MessageRoles.Application.ToString(), - WishlistId = ObjectId.Parse("ab79cde6f69abcd3efab65cd"), - CreatedById = user1.Id, - CreatedDateUtc = DateTime.UtcNow - }, - } }, new Wishlist { @@ -216,17 +197,6 @@ public class DbInitialaizer Name = "Generic Wishlist Name", Type = WishlistTypes.Product.ToString(), CreatedById = user2.Id, - Messages = new Message[] - { - new Message - { - Text = "Prompt", - Role = MessageRoles.User.ToString(), - WishlistId = ObjectId.Parse("ab6c2c2d9edf39abcd1ef9ab"), - CreatedById = user1.Id, - CreatedDateUtc = DateTime.UtcNow - } - } } }; diff --git a/ShoppingAssistantApi.Tests/ShoppingAssistantApi.Tests.csproj b/ShoppingAssistantApi.Tests/ShoppingAssistantApi.Tests.csproj index c36f3b4..85fc768 100644 --- a/ShoppingAssistantApi.Tests/ShoppingAssistantApi.Tests.csproj +++ b/ShoppingAssistantApi.Tests/ShoppingAssistantApi.Tests.csproj @@ -13,6 +13,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/ShoppingAssistantApi.Tests/TestExtentions/DbInitializer.cs b/ShoppingAssistantApi.Tests/TestExtentions/DbInitializer.cs index 811cce6..02929da 100644 --- a/ShoppingAssistantApi.Tests/TestExtentions/DbInitializer.cs +++ b/ShoppingAssistantApi.Tests/TestExtentions/DbInitializer.cs @@ -107,6 +107,8 @@ public class DbInitializer var wishlistId1 = ObjectId.Parse("ab79cde6f69abcd3efab65cd"); var wishlistId2 = ObjectId.Parse("ab6c2c2d9edf39abcd1ef9ab"); + var wishlistId3 = ObjectId.Parse("ab7c8c2d9edf39abcd1ef9ab"); + var wishlistId4 = ObjectId.Parse("ab8c8c2d9edf39abcd1ef9ab"); var wishlists = new Wishlist[] { @@ -125,7 +127,23 @@ public class DbInitializer Type = WishlistTypes.Product.ToString(), CreatedById = user2.Id, CreatedDateUtc = DateTime.UtcNow - } + }, + new Wishlist + { + Id = wishlistId3, + Name = "Test For Search", + Type = WishlistTypes.Product.ToString(), + CreatedById = user1.Id, + CreatedDateUtc = DateTime.UtcNow + }, + new Wishlist + { + Id = wishlistId4, + Name = "Test For Answer", + Type = WishlistTypes.Product.ToString(), + CreatedById = user1.Id, + CreatedDateUtc = DateTime.UtcNow + }, }; await wishlistsCollection.InsertManyAsync(wishlists); @@ -142,6 +160,8 @@ public class DbInitializer var wishlistId1 = ObjectId.Parse("ab79cde6f69abcd3efab65cd"); var wishlistId2 = ObjectId.Parse("ab6c2c2d9edf39abcd1ef9ab"); + var wishlistId3 = ObjectId.Parse("ab7c8c2d9edf39abcd1ef9ab"); + var wishlistId4 = ObjectId.Parse("ab8c8c2d9edf39abcd1ef9ab"); var messages = new Message[] { @@ -197,7 +217,23 @@ public class DbInitializer WishlistId = wishlistId2, CreatedById = user2.Id, CreatedDateUtc = DateTime.UtcNow - } + }, + new Message + { + Text = "What are you looking for?", + Role = "assistant", + WishlistId = wishlistId4, + CreatedById = user2.Id, + CreatedDateUtc = DateTime.UtcNow + }, + new Message + { + Text = "What are you looking for?", + Role = "assistant", + WishlistId = wishlistId3, + CreatedById = user2.Id, + CreatedDateUtc = DateTime.UtcNow + }, }; await messagesCollection.InsertManyAsync(messages); diff --git a/ShoppingAssistantApi.Tests/Tests/ProductsTests.cs b/ShoppingAssistantApi.Tests/Tests/ProductsTests.cs new file mode 100644 index 0000000..f9756ec --- /dev/null +++ b/ShoppingAssistantApi.Tests/Tests/ProductsTests.cs @@ -0,0 +1,70 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using MongoDB.Bson; +using Newtonsoft.Json; +using ShoppingAssistantApi.Application.Models.CreateDtos; +using ShoppingAssistantApi.Tests.TestExtentions; + +namespace ShoppingAssistantApi.Tests.Tests; + +public class ProductsTests : TestsBase +{ + public ProductsTests(TestingFactory factory) + : base(factory) + { + } + + [Fact] + public async Task StreamDataToClient_ReturnsExpectedResponse() + { + await LoginAsync("wishlists@gmail.com", "Yuiop12345"); + // Arrange + var wishlistId = "ab8c8c2d9edf39abcd1ef9ab"; + var message = new MessageCreateDto { Text = "I want new powerful laptop" }; + + // Act + var response = await _httpClient.PostAsJsonAsync($"http://127.0.0.1:5183/api/ProductsSearch/search/{wishlistId}", message); + var responseContent = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(responseContent); + } + + [Fact] + public async Task StreamDataToClientFirstly_ReturnsExpectedResponse() + { + await LoginAsync("wishlists@gmail.com", "Yuiop12345"); + // Arrange + var wishlistId = "ab7c8c2d9edf39abcd1ef9ab"; + var message = new MessageCreateDto { Text = "I want new powerful laptop" }; + + // Act + var response = await _httpClient.PostAsJsonAsync($"http://127.0.0.1:5183/api/ProductsSearch/search/{wishlistId}", message); + var responseContent = await response.Content.ReadAsStringAsync(); + var sseEvents = responseContent.Split("\n\n", StringSplitOptions.RemoveEmptyEntries); + bool foundMessageEvent = false; + + // Assert + foreach (var sseEvent in sseEvents) + { + var sseParts = sseEvent.Split('\n'); + if (sseParts.Length >= 2) + { + var eventName = sseParts[0]; + var eventData = sseParts[1].Substring("data: ".Length); + if (eventName == "event: Message") + { + foundMessageEvent = true; + Assert.NotNull(eventData); + break; + } + } + } + + Assert.True(foundMessageEvent, "Message event not found"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(responseContent); + } +} \ No newline at end of file diff --git a/ShoppingAssistantApi.UnitTests/ProductTests.cs b/ShoppingAssistantApi.UnitTests/ProductTests.cs new file mode 100644 index 0000000..9887d90 --- /dev/null +++ b/ShoppingAssistantApi.UnitTests/ProductTests.cs @@ -0,0 +1,281 @@ +using Moq; +using ShoppingAssistantApi.Application.IRepositories; +using ShoppingAssistantApi.Application.IServices; +using ShoppingAssistantApi.Application.Models.CreateDtos; +using ShoppingAssistantApi.Application.Models.Dtos; +using ShoppingAssistantApi.Application.Models.OpenAi; +using ShoppingAssistantApi.Application.Paging; +using ShoppingAssistantApi.Domain.Entities; +using ShoppingAssistantApi.Domain.Enums; +using ShoppingAssistantApi.Infrastructure.Services; +using System.Linq.Expressions; + +namespace ShoppingAssistantApi.Tests.Tests; + +public class ProductTests +{ + private Mock _openAiServiceMock; + + private IProductService _productService; + + private Mock _wishListServiceMock; + + private Mock _messagesRepositoryMock; + + public ProductTests() + { + _messagesRepositoryMock = new Mock(); + _openAiServiceMock = new Mock(); + _wishListServiceMock = new Mock(); + _productService = new ProductService(_openAiServiceMock.Object, _wishListServiceMock.Object, _messagesRepositoryMock.Object); + } + + [Fact] + public async Task SearchProductAsync_WhenWishlistsWithoutMessages_ReturnsExpectedEvents() + { + // Arrange + string wishlistId = "existingWishlistId"; + var message = new MessageCreateDto + { + Text = "Your message text here" + }; + var cancellationToken = CancellationToken.None; + + // Define your expected SSE data for the test + var expectedSseData = new List + { + "[", "Message", "]", " What", " u", " want", " ?", "[", "Options", "]", " USB-C", " ;", " Keyboard", " ultra", + " ;", "[", "Options", "]", " USB", "-C", " ;", "[", "Products", "]", " GTX", " 3090", " ;", " GTX", + " 3070TI", " ;", " GTX", " 4070TI", " ;", " ?" + }; + + var expectedMessages = new List { " What", " u", " want", " ?" }; + var expectedSuggestion = new List { " USB-C", " Keyboard ultra", " USB-C" }; + + // Mock the GetChatCompletionStream method to provide the expected SSE data + _openAiServiceMock.Setup(x => x.GetChatCompletionStream(It.IsAny(), cancellationToken)) + .Returns(expectedSseData.ToAsyncEnumerable()); + + _messagesRepositoryMock.Setup(m => m.GetCountAsync(It.IsAny>>(), It.IsAny())) + .ReturnsAsync(1); + + _wishListServiceMock.Setup(w => w.AddMessageToPersonalWishlistAsync(wishlistId, It.IsAny(), cancellationToken)) + .Verifiable(); + + _wishListServiceMock + .Setup(m => m.GetMessagesPageFromPersonalWishlistAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()) + ) + .ReturnsAsync(new PagedList( + new List + { + new MessageDto + { + Text = "What are you looking for?", + Id = "3", + CreatedById = "User2", + Role = "User" + }, + }, + 1, + 1, + 1 + )); + + // Act + var resultStream = _productService.SearchProductAsync(wishlistId, message, cancellationToken); + + // Convert the result stream to a list of ServerSentEvent + var actualSseEvents = await resultStream.ToListAsync(); + + var receivedMessages = actualSseEvents + .Where(e => e.Event == SearchEventType.Message) + .Select(e => e.Data) + .ToList(); + + var receivedSuggestions = actualSseEvents + .Where(e => e.Event == SearchEventType.Suggestion) + .Select(e => e.Data) + .ToList(); + + // Assert + // Check if the actual SSE events match the expected SSE events + Assert.NotNull(actualSseEvents); + Assert.Equal(expectedMessages, receivedMessages); + Assert.Equal(expectedSuggestion, receivedSuggestions); + } + + + [Fact] + public async void SearchProductAsync_WithExistingMessageInWishlist_ReturnsExpectedEvents() + { + // Arrange + var wishlistId = "your_wishlist_id"; + var message = new MessageCreateDto { Text = "Your message text" }; + var cancellationToken = new CancellationToken(); + + var productService = _productService; + + var expectedSseData = new List + { + "[", "Message", "]", " What", " u", " want", " ?", "[", "Options", "]", "USB-C", " ;", "Keyboard", " ultra", + " ;", "[", "Options", "]", "USB", "-C", " ;" + }; + + var expectedMessages = new List { " What", " u", " want", " ?" }; + var expectedSuggestions = new List { "USB-C", "Keyboard ultra", "USB-C" }; + + // Mock the GetChatCompletionStream method to provide the expected SSE data + _openAiServiceMock.Setup(x => x.GetChatCompletionStream(It.IsAny(), cancellationToken)) + .Returns(expectedSseData.ToAsyncEnumerable()); + + _messagesRepositoryMock.Setup(m => m.GetCountAsync(It.IsAny>>(), It.IsAny())) + .ReturnsAsync(3); + + _wishListServiceMock.Setup(w => w.AddMessageToPersonalWishlistAsync(wishlistId, It.IsAny(), cancellationToken)) + .Verifiable(); + + _wishListServiceMock + .Setup(w => w.GetMessagesPageFromPersonalWishlistAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new PagedList(new List + { + new MessageDto + { + Text = "Message 1", + Id = "1", + CreatedById = "User2", + Role = "User" + }, + new MessageDto + { + Text = "Message 2", + Id = "2", + CreatedById = "User2", + Role = "User" + }, + new MessageDto + { + Text = "Message 3", + Id = "3", + CreatedById = "User2", + Role = "User" + }, + }, 1, 3, 3)); + + // Act + var resultStream = _productService.SearchProductAsync(wishlistId, message, cancellationToken); + + // Convert the result stream to a list of ServerSentEvent + var actualSseEvents = await resultStream.ToListAsync(); + + var receivedMessages = actualSseEvents + .Where(e => e.Event == SearchEventType.Message) + .Select(e => e.Data) + .ToList(); + + var receivedSuggestions = actualSseEvents + .Where(e => e.Event == SearchEventType.Suggestion) + .Select(e => e.Data) + .ToList(); + // Assert + + Assert.NotNull(actualSseEvents); + Assert.Equal(expectedMessages, receivedMessages); + Assert.Equal(expectedSuggestions, receivedSuggestions); + _wishListServiceMock.Verify(w => w.AddMessageToPersonalWishlistAsync(wishlistId, It.IsAny(), cancellationToken), Times.Once); + } + + + [Fact] + public async void SearchProductAsync_WithExistingMessageInWishlistAndAddProduct_ReturnsExpectedEvents() + { + // Arrange + var wishlistId = "your_wishlist_id"; + var message = new MessageCreateDto { Text = "Your message text" }; + var cancellationToken = new CancellationToken(); + + var productService = _productService; + + var expectedSseData = new List + { + "[", "Message", "]", " What", " u", " want", " ?", "[", "Options", "]", "USB-C", " ;", "Keyboard", " ultra", + " ;", "[", "Options", "]", "USB", "-C", " ;", "[", "Products", "]", " GTX", " 3090", " ;", " GTX", + " 3070TI", " ;", " GTX", " 4070TI", " ;", " ?" + }; + + var expectedMessages = new List { " What", " u", " want", " ?" }; + var expectedSuggestions = new List { "USB-C", "Keyboard ultra", "USB-C" }; + + // Mock the GetChatCompletionStream method to provide the expected SSE data + _openAiServiceMock.Setup(x => x.GetChatCompletionStream(It.IsAny(), cancellationToken)) + .Returns(expectedSseData.ToAsyncEnumerable()); + + _messagesRepositoryMock.Setup(m => m.GetCountAsync(It.IsAny>>(), It.IsAny())) + .ReturnsAsync(3); + + _wishListServiceMock + .Setup(w => w.AddProductToPersonalWishlistAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .Verifiable(); + + _wishListServiceMock.Setup(w => w.AddProductToPersonalWishlistAsync(wishlistId, It.IsAny(), cancellationToken)) + .Verifiable(); + + _wishListServiceMock + .Setup(w => w.GetMessagesPageFromPersonalWishlistAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new PagedList(new List + { + new MessageDto + { + Text = "Message 1", + Id = "1", + CreatedById = "User2", + Role = "User" + }, + new MessageDto + { + Text = "Message 2", + Id = "2", + CreatedById = "User2", + Role = "User" + }, + new MessageDto + { + Text = "Message 3", + Id = "3", + CreatedById = "User2", + Role = "User" + }, + }, 1, 3, 3)); + + // Act + var resultStream = _productService.SearchProductAsync(wishlistId, message, cancellationToken); + + // Convert the result stream to a list of ServerSentEvent + var actualSseEvents = await resultStream.ToListAsync(); + + var receivedMessages = actualSseEvents + .Where(e => e.Event == SearchEventType.Message) + .Select(e => e.Data) + .ToList(); + + var receivedSuggestions = actualSseEvents + .Where(e => e.Event == SearchEventType.Suggestion) + .Select(e => e.Data) + .ToList(); + + // Assert + Assert.NotNull(actualSseEvents); + Assert.Equal(expectedMessages, receivedMessages); + Assert.Equal(expectedSuggestions, receivedSuggestions); + _wishListServiceMock.Verify(w => w.AddProductToPersonalWishlistAsync( + It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(3)); + _wishListServiceMock.Verify(w => w.AddMessageToPersonalWishlistAsync( + wishlistId, It.IsAny(), cancellationToken), Times.Once); + } +} \ No newline at end of file diff --git a/ShoppingAssistantApi.UnitTests/ShoppingAssistantApi.UnitTests.csproj b/ShoppingAssistantApi.UnitTests/ShoppingAssistantApi.UnitTests.csproj index 9274a65..05ef7fc 100644 --- a/ShoppingAssistantApi.UnitTests/ShoppingAssistantApi.UnitTests.csproj +++ b/ShoppingAssistantApi.UnitTests/ShoppingAssistantApi.UnitTests.csproj @@ -10,8 +10,10 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive @@ -23,9 +25,9 @@ - - - + + +