diff --git a/.github/workflows/azure_deploy.yml b/.github/workflows/azure_deploy.yml new file mode 100644 index 0000000..e3e17ba --- /dev/null +++ b/.github/workflows/azure_deploy.yml @@ -0,0 +1,50 @@ +name: Build, Test, and Deploy to Azure + +on: + workflow_dispatch: + push: + branches: + - main + +env: + AZURE_WEBAPP_NAME: chat-service-ali-nadim + AZURE_WEBAPP_PACKAGE_PATH: './publish' + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + steps: + + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: '6.0.x' + + - name: Restore dependencies + run: dotnet restore ./ChatService.sln + + - name: Build + run: dotnet build ./ChatService.sln --configuration Release --no-restore + + - name: Run unit tests + run: dotnet test ChatService.Web.Tests/bin/Release/net6.0/ChatService.Web.Tests.dll + + - name: Run integration tests + run: dotnet test ChatService.Web.IntegrationTests/bin/Release/net6.0/ChatService.Web.IntegrationTests.dll + env: + Cosmos:ConnectionString: ${{ secrets.COSMOS_CONNECTIONSTRING }} + BlobStorage:ConnectionString: ${{ secrets.BLOBSTORAGE_CONNECTIONSTRING }} + + - name: Publish + run: dotnet publish --configuration Release --output '${{ env.AZURE_WEBAPP_PACKAGE_PATH }}' --no-restore ChatService.Web + + - name: Deploy + uses: azure/webapps-deploy@v2 + with: + app-name: ${{ env.AZURE_WEBAPP_NAME }} + publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_CHATSERVICE }} + package: ${{ env.AZURE_WEBAPP_PACKAGE_PATH }} diff --git a/.github/workflows/prbuild.yml b/.github/workflows/prbuild.yml new file mode 100644 index 0000000..71fe75b --- /dev/null +++ b/.github/workflows/prbuild.yml @@ -0,0 +1,35 @@ +name: PR Build + +on: + pull_request: + branches: [ "main" ] + +jobs: + build-and-test: + + runs-on: ubuntu-latest + + steps: + + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 6.0.x + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --configuration Release --no-restore + + - name: Run unit tests + run: dotnet test ChatService.Web.Tests/bin/Release/net6.0/ChatService.Web.Tests.dll + + - name: Run integration tests + run: dotnet test ChatService.Web.IntegrationTests/bin/Release/net6.0/ChatService.Web.IntegrationTests.dll + env: + Cosmos:ConnectionString: ${{ secrets.COSMOS_CONNECTIONSTRING }} + BlobStorage:ConnectionString: ${{ secrets.BLOBSTORAGE_CONNECTIONSTRING }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..26958af --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +bin/ +obj/ +/packages/ +riderModule.iml +/_ReSharper.Caches/ +ChatService.sln.DotSettings.user \ No newline at end of file diff --git a/.idea/.idea.ChatService/.idea/.gitignore b/.idea/.idea.ChatService/.idea/.gitignore new file mode 100644 index 0000000..68a553e --- /dev/null +++ b/.idea/.idea.ChatService/.idea/.gitignore @@ -0,0 +1,13 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/contentModel.xml +/.idea.ChatService.iml +/modules.xml +/projectSettingsUpdater.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/.idea.ChatService/.idea/indexLayout.xml b/.idea/.idea.ChatService/.idea/indexLayout.xml new file mode 100644 index 0000000..7b08163 --- /dev/null +++ b/.idea/.idea.ChatService/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/.idea.ChatService/.idea/vcs.xml b/.idea/.idea.ChatService/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/.idea.ChatService/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/ChatService.Web.IntegrationTests/BlobImageStoreTests.cs b/ChatService.Web.IntegrationTests/BlobImageStoreTests.cs new file mode 100644 index 0000000..babf34d --- /dev/null +++ b/ChatService.Web.IntegrationTests/BlobImageStoreTests.cs @@ -0,0 +1,99 @@ +using System.Text; +using Azure; +using ChatService.Web.Dtos; +using ChatService.Web.Storage; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace ChatService.Web.IntegrationTests; + +public class BlobImageStoreTests : IClassFixture>, IAsyncLifetime +{ + private readonly IImageStore _imageStore; + private readonly Image _image = new Image("image/jpg", + new MemoryStream(Encoding.UTF8.GetBytes("This is a mock image file content"))); + private string _imageId; + + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + await _imageStore.DeleteImage(_imageId); + } + + public BlobImageStoreTests(WebApplicationFactory factory) + { + _imageStore = factory.Services.GetRequiredService(); + } + + [Fact] + public async Task UploadImage_Success() + { + string imageId = await _imageStore.UploadImage(_image); + var downloadedImage = await _imageStore.DownloadImage(imageId); + + Assert.Equal(_image.ContentType, downloadedImage.ContentType); + Assert.True(_image.Content.ToArray().SequenceEqual(downloadedImage.Content.ToArray())); + + _imageId = imageId; + } + + [Fact] + public async Task UploadImage_Failure() + { + var notImage = new Image("text/plain", + new MemoryStream(Encoding.UTF8.GetBytes("This is a mock file simulating an invalid image type"))); + + await Assert.ThrowsAsync(async () => await _imageStore.UploadImage(notImage)); + } + + [Fact] + public async Task DownloadImage_Success() + { + var imageId = await _imageStore.UploadImage(_image); + var downloadedImage = await _imageStore.DownloadImage(imageId); + + Assert.Equal(_image.ContentType, downloadedImage.ContentType); + Assert.True(_image.Content.ToArray().SequenceEqual(downloadedImage.Content.ToArray())); + } + + [Fact] + public async Task DownloadImage_NotFound() + { + var downloadedImage = await _imageStore.DownloadImage("dummy_id"); + + Assert.Null(downloadedImage); + } + + [Fact] + public async Task DeleteImage_Success() + { + var imageId = await _imageStore.UploadImage(_image); + Assert.True(await _imageStore.DeleteImage(imageId)); + } + + [Fact] + public async Task DeleteImage_Failure() + { + Assert.False(await _imageStore.DeleteImage("dummy_id")); + } + + [Fact] + public async Task ImageExists_Exists() + { + string imageId = await _imageStore.UploadImage(_image); + + Assert.True(await _imageStore.ImageExists(imageId)); + + _imageId = imageId; + } + + [Fact] + public async Task ImageExists_DoesntExist() + { + Assert.False(await _imageStore.ImageExists("dummy_id")); + } +} \ No newline at end of file diff --git a/ChatService.Web.IntegrationTests/ChatService.Web.IntegrationTests.csproj b/ChatService.Web.IntegrationTests/ChatService.Web.IntegrationTests.csproj new file mode 100644 index 0000000..3f79c19 --- /dev/null +++ b/ChatService.Web.IntegrationTests/ChatService.Web.IntegrationTests.csproj @@ -0,0 +1,32 @@ + + + + net6.0 + enable + enable + + false + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/ChatService.Web.IntegrationTests/CosmosConversationStoreTests.cs b/ChatService.Web.IntegrationTests/CosmosConversationStoreTests.cs new file mode 100644 index 0000000..d60f705 --- /dev/null +++ b/ChatService.Web.IntegrationTests/CosmosConversationStoreTests.cs @@ -0,0 +1,259 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Enums; +using ChatService.Web.Exceptions; +using ChatService.Web.Storage; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace ChatService.Web.IntegrationTests; + +public class CosmosConversationStoreTests : IClassFixture>, IAsyncLifetime +{ + private readonly IUserConversationStore _userConversationStore; + + private static readonly UserConversation _userConversation = new UserConversation + { + Username = Guid.NewGuid().ToString(), + ConversationId = Guid.NewGuid().ToString() + "_", + LastModifiedTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + }; + + private readonly UserConversation _userConversation1 = new UserConversation + { + Username = _userConversation.Username, + ConversationId = Guid.NewGuid().ToString() + "_", + LastModifiedTime = 100 + }; + + private readonly UserConversation _userConversation2 = new UserConversation + { + Username = _userConversation.Username, + ConversationId = Guid.NewGuid().ToString() + "_", + LastModifiedTime = 200 + }; + + private readonly UserConversation _userConversation3 = new UserConversation + { + Username = _userConversation.Username, + ConversationId = Guid.NewGuid().ToString() + "_", + LastModifiedTime = 300 + }; + + public CosmosConversationStoreTests(WebApplicationFactory factory) + { + _userConversationStore = factory.Services.GetRequiredService(); + } + + [Fact] + public async Task CreateUserConversation_Successful() + { + await _userConversationStore.CreateUserConversation(_userConversation); + + Assert.Equal(_userConversation, await _userConversationStore.GetUserConversation(_userConversation.Username, _userConversation.ConversationId)); + } + + [Theory] + [InlineData(null, "dummy_conversationId", 100)] + [InlineData("", "dummy_conversationId", 100)] + [InlineData(" ", "dummy_conversationId", 100)] + [InlineData("foobar", null, 100)] + [InlineData("foobar", "", 100)] + [InlineData("foobar", " ", 100)] + [InlineData("foobar", "dummy_conversationId", -100)] + public async Task CreateUserConversation_InvalidArguments(string username, string conversationId, long lastModifiedTime) + { + UserConversation userConversation = new() + { + Username = username, + ConversationId = conversationId, + LastModifiedTime = lastModifiedTime + }; + + await Assert.ThrowsAsync( + () => _userConversationStore.CreateUserConversation(userConversation)); + } + + [Fact] + public async Task CreateUserConversation_ConversationAlreadyExists() + { + await _userConversationStore.CreateUserConversation(_userConversation); + + await Assert.ThrowsAsync( + () => _userConversationStore.CreateUserConversation(_userConversation)); + } + + [Theory] + [InlineData(null, "dummy_conversationId")] + [InlineData("", "dummy_conversationId")] + [InlineData(" ", "dummy_conversationId")] + [InlineData("foobar", null)] + [InlineData("foobar", "")] + [InlineData("foobar", " ")] + public async Task GetUserConversation_InvalidArguments(string username, string conversationId) + { + await Assert.ThrowsAsync( + () => _userConversationStore.GetUserConversation(username, conversationId)); + } + + [Fact] + public async Task GetUserConversation_ConversationNotFound() + { + await Assert.ThrowsAsync( + () => _userConversationStore.GetUserConversation(_userConversation.Username, _userConversation.ConversationId)); + } + + [Fact] + public async Task GetUserConversations_Limit() + { + await AddMultipleUserConversations(_userConversation1, _userConversation2, _userConversation3); + + var response = await _userConversationStore.GetUserConversations(_userConversation.Username, 1, OrderBy.ASC, null, 1); + Assert.Equal(1, response.UserConversations.Count); + + response = await _userConversationStore.GetUserConversations(_userConversation.Username, 2, OrderBy.ASC, null, 1); + Assert.Equal(2, response.UserConversations.Count); + + response = await _userConversationStore.GetUserConversations(_userConversation.Username, 3, OrderBy.ASC, null, 1); + Assert.Equal(3, response.UserConversations.Count); + + await DeleteMultipleUserConversations(_userConversation1, _userConversation2, _userConversation3); + } + + [Theory] + [InlineData(OrderBy.ASC)] + [InlineData(OrderBy.DESC)] + public async Task GetUserConversations_OrderBy(OrderBy orderBy) + { + List userConversationsExpected = CreateListOfUserConversations( + _userConversation1, _userConversation2, _userConversation3, _userConversation); + + await AddMultipleUserConversations( + _userConversation, _userConversation1, _userConversation2, _userConversation3); + + var response = await _userConversationStore.GetUserConversations(_userConversation.Username, 10, orderBy, null, 0); + + if (orderBy == OrderBy.ASC) + { + Assert.Equal(userConversationsExpected, response.UserConversations); + } + else + { + userConversationsExpected.Reverse(); + Assert.Equal(userConversationsExpected, response.UserConversations); + } + + await DeleteMultipleUserConversations(_userConversation1, _userConversation2, _userConversation3); + } + + [Fact] + public async Task GetUserConversations_ContinuationTokenValidity() + { + await AddMultipleUserConversations(_userConversation1, _userConversation2, _userConversation3); + + var response = await _userConversationStore.GetUserConversations( + _userConversation.Username, 1, OrderBy.ASC, null, 1); + + Assert.Equal(_userConversation1, response.UserConversations.ElementAt(0)); + + var nextContinuation = response.NextContinuationToken; + Assert.NotNull(nextContinuation); + + response = await _userConversationStore.GetUserConversations(_userConversation.Username, 1, OrderBy.ASC, nextContinuation, 1); + Assert.Equal(_userConversation2, response.UserConversations.ElementAt(0)); + + nextContinuation = response.NextContinuationToken; + Assert.NotNull(nextContinuation); + + response = await _userConversationStore.GetUserConversations(_userConversation.Username, 1, OrderBy.ASC, nextContinuation, 1); + Assert.Equal(_userConversation3, response.UserConversations.ElementAt(0)); + + nextContinuation = response.NextContinuationToken; + Assert.Null(nextContinuation); + + await DeleteMultipleUserConversations(_userConversation1, _userConversation2, _userConversation3); + } + + [Theory] + [InlineData(0)] + [InlineData(100)] + [InlineData(200)] + [InlineData(300)] + public async Task GetUserConversations_LastSeenConversationTime(long lastSeenConversationTime) + { + await AddMultipleUserConversations(_userConversation1, _userConversation2, _userConversation3, _userConversation); + + List userConversationsExpected = new(); + + if(_userConversation1.LastModifiedTime > lastSeenConversationTime) { userConversationsExpected.Add(_userConversation1); } + if(_userConversation2.LastModifiedTime > lastSeenConversationTime) { userConversationsExpected.Add(_userConversation2);} + if(_userConversation3.LastModifiedTime > lastSeenConversationTime) { userConversationsExpected.Add(_userConversation3);} + if(_userConversation.LastModifiedTime > lastSeenConversationTime) { userConversationsExpected.Add(_userConversation);} + + var response = await _userConversationStore.GetUserConversations(_userConversation.Username, 10, OrderBy.ASC, null, lastSeenConversationTime); + + Assert.Equal(userConversationsExpected, response.UserConversations); + + await DeleteMultipleUserConversations(_userConversation1, _userConversation2, _userConversation3); + } + + [Theory] + [InlineData("", 1, 100)] + [InlineData(" ", 1, 100)] + [InlineData(null, 0, 100)] + [InlineData("username", 0, 100)] + [InlineData("username", -1, 100)] + [InlineData("username", 10, -100)] + public async Task GetUserConversations_InvalidArguments(string username, int limit, long lastSeenConversationTime) + { + await Assert.ThrowsAsync( + () => _userConversationStore.GetUserConversations(username, limit, OrderBy.ASC, null, lastSeenConversationTime)); + } + + [Fact] + public async Task GetUserConversations_InvalidContinuationToken() + { + string invalidContinuationToken = Guid.NewGuid().ToString(); + + await Assert.ThrowsAsync( + () => _userConversationStore.GetUserConversations( + _userConversation.Username, 10, OrderBy.DESC, invalidContinuationToken, 0)); + } + + private async Task AddMultipleUserConversations(params UserConversation[] userConversations) + { + foreach (UserConversation userConversation in userConversations) + { + await _userConversationStore.CreateUserConversation(userConversation); + } + } + + private List CreateListOfUserConversations(params UserConversation[] userConversations) + { + List list = new(); + + foreach (UserConversation userConversation in userConversations) + { + list.Add(userConversation); + } + + return list; + } + + private async Task DeleteMultipleUserConversations(params UserConversation[] userConversations) + { + foreach (UserConversation userConversation in userConversations) + { + await _userConversationStore.DeleteUserConversation(userConversation.Username, userConversation.ConversationId); + } + } + + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + await _userConversationStore.DeleteUserConversation(_userConversation.Username, _userConversation.ConversationId); + } +} \ No newline at end of file diff --git a/ChatService.Web.IntegrationTests/CosmosMessageStoreTests.cs b/ChatService.Web.IntegrationTests/CosmosMessageStoreTests.cs new file mode 100644 index 0000000..bce1098 --- /dev/null +++ b/ChatService.Web.IntegrationTests/CosmosMessageStoreTests.cs @@ -0,0 +1,255 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Enums; +using ChatService.Web.Exceptions; +using ChatService.Web.Storage; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace ChatService.Web.IntegrationTests; + +public class CosmosMessageStoreTests : IClassFixture>, IAsyncLifetime +{ + private readonly IMessageStore _messageStore; + + private readonly string _conversationId = Guid.NewGuid().ToString() + "_"; + + private readonly Message _message1 = new Message + { + MessageId = Guid.NewGuid().ToString(), + UnixTime = 100, + SenderUsername = Guid.NewGuid().ToString(), + Text = "text of _message1" + }; + + private readonly Message _message2 = new Message + { + MessageId = Guid.NewGuid().ToString(), + UnixTime = 200, + SenderUsername = Guid.NewGuid().ToString(), + Text = "text of _message2" + }; + + private readonly Message _message3 = new Message + { + MessageId = Guid.NewGuid().ToString(), + UnixTime = 300, + SenderUsername = Guid.NewGuid().ToString(), + Text = "text of _message3" + }; + + public CosmosMessageStoreTests(WebApplicationFactory factory) + { + _messageStore = factory.Services.GetRequiredService(); + } + + [Fact] + public async Task AddMessage_Successful() + { + await _messageStore.AddMessage(_conversationId, _message1); + + Assert.Equal(_message1, await _messageStore.GetMessage(_conversationId, _message1.MessageId)); + } + + [Theory] + [InlineData(null, "senderUsername", "text", 100)] + [InlineData("", "senderUsername", "text", 100)] + [InlineData(" ", "senderUsername", "text", 100)] + [InlineData("id", null, "text", 100)] + [InlineData("id", "", "text", 100)] + [InlineData("id", " ", "text", 100)] + [InlineData("id", "senderUsername", null, 100)] + [InlineData("id", "senderUsername", "", 100)] + [InlineData("id", "senderUsername", " ", 100)] + [InlineData("id", "senderUsername", "text", -100)] + public async Task AddMessage_InvalidArguments(string id, string senderUsername, string text, long unixTime) + { + Message message = new Message + { + MessageId = id, + UnixTime = unixTime, + SenderUsername = senderUsername, + Text = text + }; + + await Assert.ThrowsAsync(() => _messageStore.AddMessage(_conversationId, message)); + } + + [Fact] + public async Task AddMessage_MessageAlreadyExists() + { + await _messageStore.AddMessage(_conversationId, _message1); + + await Assert.ThrowsAsync(() => _messageStore.AddMessage(_conversationId, _message1)); + } + + [Theory] + [InlineData(null, "messageId")] + [InlineData("", "messageId")] + [InlineData(" ", "messageId")] + [InlineData("conversationId", null)] + [InlineData("conversationId", "")] + [InlineData("conversationId", " ")] + public async Task GetMessage_InvalidArguments(string conversationId, string messageId) + { + await Assert.ThrowsAsync(() => _messageStore.GetMessage(conversationId, messageId)); + } + + [Fact] + public async Task GetMessage_MessageNotFound() + { + await Assert.ThrowsAsync(() => _messageStore.GetMessage(_conversationId, _message1.MessageId)); + } + + [Fact] + public async Task GetMessages_Limit() + { + await AddMultipleMessages(_conversationId, _message1, _message2, _message3); + + var response = await _messageStore.GetMessages( + _conversationId, 1, OrderBy.ASC, null, 1); + Assert.Equal(1, response.Messages.Count); + + response = await _messageStore.GetMessages(_conversationId, 2, OrderBy.ASC, null, 1); + Assert.Equal(2, response.Messages.Count); + + response = await _messageStore.GetMessages(_conversationId, 3, OrderBy.ASC, null, 1); + Assert.Equal(3, response.Messages.Count); + + await DeleteMultipleMessages(_conversationId, _message1, _message2, _message3); + } + + [Theory] + [InlineData(OrderBy.ASC)] + [InlineData(OrderBy.DESC)] + public async Task GetMessages_OrderBy(OrderBy orderBy) + { + await AddMultipleMessages(_conversationId, _message1, _message2); + + List messagesExpected = new(); + messagesExpected.Add(_message1); + messagesExpected.Add(_message2); + + var response = await _messageStore.GetMessages( + _conversationId, 10, orderBy, null, 1); + + if (orderBy == OrderBy.ASC) + { + Assert.Equal(messagesExpected, response.Messages); + } + else + { + messagesExpected.Reverse(); + Assert.Equal(messagesExpected, response.Messages); + } + + await _messageStore.DeleteMessage(_conversationId, _message2.MessageId); + } + + [Fact] + public async Task GetMessages_ContinuationTokenValidity() + { + await AddMultipleMessages(_conversationId, _message1, _message2, _message3); + + var response = await _messageStore.GetMessages( + _conversationId, 1, OrderBy.ASC, null, 1); + Assert.Equal(_message1, response.Messages.ElementAt(0)); + Assert.NotNull(response.NextContinuationToken); + + response = await _messageStore.GetMessages( + _conversationId, 1, OrderBy.ASC, response.NextContinuationToken, 1); + Assert.Equal(_message2, response.Messages.ElementAt(0)); + Assert.NotNull(response.NextContinuationToken); + + response = await _messageStore.GetMessages( + _conversationId, 1, OrderBy.ASC, response.NextContinuationToken, 1); + Assert.Equal(_message3, response.Messages.ElementAt(0)); + Assert.Null(response.NextContinuationToken); + + await DeleteMultipleMessages(_conversationId, _message2, _message3); + } + + [Theory] + [InlineData(0)] + [InlineData(100)] + [InlineData(200)] + [InlineData(300)] + public async Task GetMessages_LastSeenMessageTime(long lastSeenMessageTime) + { + await AddMultipleMessages(_conversationId, _message1, _message2, _message3); + + List messagesExpected = new(); + if(_message1.UnixTime > lastSeenMessageTime) messagesExpected.Add(_message1); + if(_message2.UnixTime > lastSeenMessageTime) messagesExpected.Add(_message2); + if(_message3.UnixTime > lastSeenMessageTime) messagesExpected.Add(_message3); + + var response = await _messageStore.GetMessages( + _conversationId, 10, OrderBy.ASC, null, lastSeenMessageTime); + + Assert.Equal(messagesExpected, response.Messages); + + await DeleteMultipleMessages(_conversationId, _message2, _message3); + } + + [Theory] + [InlineData(null, 10, 100)] + [InlineData("", 10, 100)] + [InlineData(" ", 10, 100)] + [InlineData("conversationId", 0, 100)] + [InlineData("conversationId", -10, 100)] + [InlineData("conversationId", 10, -100)] + public async Task GetMessages_InvalidArguments(string conversationId, int limit, long lastSeenMessageTime) + { + Assert.ThrowsAsync(() => + _messageStore.GetMessages(conversationId, limit, OrderBy.ASC, null, lastSeenMessageTime)); + } + + [Fact] + public async Task GetMessages_InvalidContinuationToken() + { + string invalidContinuationToken = Guid.NewGuid().ToString(); + + await Assert.ThrowsAsync( + () => _messageStore.GetMessages( + _conversationId, 10, OrderBy.DESC, invalidContinuationToken, 0)); + } + + [Fact] + public async Task ConversationPartitionExists_Exists() + { + await _messageStore.AddMessage(_conversationId, _message1); + + Assert.True(await _messageStore.ConversationPartitionExists(_conversationId)); + } + + [Fact] + public async Task ConversationPartitionExists_DoesNotExists() + { + Assert.False(await _messageStore.ConversationPartitionExists(_conversationId)); + } + + private async Task AddMultipleMessages(string conversationId, params Message[] messages) + { + foreach (Message message in messages) + { + await _messageStore.AddMessage(conversationId, message); + } + } + + private async Task DeleteMultipleMessages(string conversationId, params Message[] messages) + { + foreach (Message message in messages) + { + await _messageStore.DeleteMessage(conversationId, message.MessageId); + } + } + + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + await _messageStore.DeleteMessage(_conversationId, _message1.MessageId); + } +} \ No newline at end of file diff --git a/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs b/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs new file mode 100644 index 0000000..3395041 --- /dev/null +++ b/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs @@ -0,0 +1,109 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Exceptions; +using ChatService.Web.Storage; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace ChatService.Web.IntegrationTests; + +public class CosmosProfileStoreTest : IClassFixture>, IAsyncLifetime +{ + private readonly IProfileStore _profileStore; + + private readonly Profile _profile = new Profile + { + Username = Guid.NewGuid().ToString(), + FirstName = "Foo", + LastName = "Bar", + ProfilePictureId = "dummy_id" + }; + + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + await _profileStore.DeleteProfile(_profile.Username); + } + + public CosmosProfileStoreTest(WebApplicationFactory factory) + { + _profileStore = factory.Services.GetRequiredService(); + } + + [Fact] + public async Task AddNewProfile_Success() + { + await _profileStore.AddProfile(_profile); + Assert.Equal(_profile, await _profileStore.GetProfile(_profile.Username)); + } + + [Theory] + [InlineData(null, "Foo", "Bar", "dummy_id")] + [InlineData("", "Foo", "Bar", "dummy_id")] + [InlineData(" ", "Foo", "Bar", "dummy_id")] + [InlineData("foobar", null, "Bar", "dummy_id")] + [InlineData("foobar", "", "Bar", "dummy_id")] + [InlineData("foobar", " ", "Bar", "dummy_id")] + [InlineData("foobar", "Foo", null, "dummy_id")] + [InlineData("foobar", "Foo", "", "dummy_id")] + [InlineData("foobar", "Foo", " ", "dummy_id")] + [InlineData("foobar", "Foo", "Bar", null)] + [InlineData("foobar", "Foo", "Bar","")] + [InlineData("foobar", "Foo", "Bar"," ")] + public async Task AddNewProfile_InvalidArgs(string username, string firstName, string lastName, string profilePictureId) + { + Profile profile = new Profile + { + Username = username, + FirstName = firstName, + LastName = lastName, + ProfilePictureId = profilePictureId + }; + + await Assert.ThrowsAsync( async () => await _profileStore.AddProfile(profile)); + } + + [Fact] + public async Task AddNewProfile_NullProfile() + { + await Assert.ThrowsAsync( async () => await _profileStore.AddProfile(null)); + } + + [Fact] + public async Task AddNewProfile_UsernameTaken() + { + await _profileStore.AddProfile(_profile); + await Assert.ThrowsAsync( async () => await _profileStore.AddProfile(_profile)); + } + + [Fact] + public async Task GetNonExistingProfile() + { + Assert.Null(await _profileStore.GetProfile(_profile.Username)); + } + + [Fact] + public async Task DeleteProfile() + { + await _profileStore.AddProfile(_profile); + Assert.Equal(_profile, await _profileStore.GetProfile(_profile.Username)); + await _profileStore.DeleteProfile(_profile.Username); + Assert.Null(await _profileStore.GetProfile(_profile.Username)); + } + + [Fact] + public async Task ProfileExists_Exists() + { + await _profileStore.AddProfile(_profile); + Assert.True(await _profileStore.ProfileExists(_profile.Username)); + } + + [Fact] + public async Task ProfileExists_DoesNotExist() + { + Assert.False(await _profileStore.ProfileExists(_profile.Username)); + } +} \ No newline at end of file diff --git a/ChatService.Web.IntegrationTests/Usings.cs b/ChatService.Web.IntegrationTests/Usings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/ChatService.Web.IntegrationTests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/ChatService.Web.Tests/ChatService.Web.Tests.csproj b/ChatService.Web.Tests/ChatService.Web.Tests.csproj new file mode 100644 index 0000000..d2ff97f --- /dev/null +++ b/ChatService.Web.Tests/ChatService.Web.Tests.csproj @@ -0,0 +1,32 @@ + + + + net6.0 + enable + enable + + false + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/ChatService.Web.Tests/Controllers/ConversationsControllerTests.cs b/ChatService.Web.Tests/Controllers/ConversationsControllerTests.cs new file mode 100644 index 0000000..f233245 --- /dev/null +++ b/ChatService.Web.Tests/Controllers/ConversationsControllerTests.cs @@ -0,0 +1,345 @@ +using System.Net; +using System.Net.Http.Json; +using ChatService.Web.Dtos; +using ChatService.Web.Enums; +using ChatService.Web.Exceptions; +using ChatService.Web.Services; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Newtonsoft.Json; + +namespace ChatService.Web.Tests.Controllers; + +public class ConversationsControllerTests : IClassFixture> +{ + private readonly HttpClient _httpClient; + private readonly Mock _userConversationServiceMock = new(); + private readonly Mock _messageServiceMock = new(); + + private static readonly string _username = Guid.NewGuid().ToString(); + + private static readonly SendMessageRequest _sendMessageRequest = new SendMessageRequest + { + MessageId = Guid.NewGuid().ToString(), + SenderUsername = _username, + Text = "Hello" + }; + + private readonly string _conversationId = Guid.NewGuid().ToString(); + + private readonly long _unixTimeNow = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + private readonly string _nextContinuationToken = Guid.NewGuid().ToString(); + + private readonly StartConversationRequest _startConversationRequest = new StartConversationRequest + { + Participants = new List { _username, Guid.NewGuid().ToString() }, + FirstMessage = _sendMessageRequest + }; + + public ConversationsControllerTests(WebApplicationFactory factory) + { + _httpClient = factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddSingleton(_userConversationServiceMock.Object); + services.AddSingleton(_messageServiceMock.Object); + }); + }).CreateClient(); + } + + [Fact] + public async Task GetUserConversations_Success() + { + List conversations = new(); + conversations.Add(new Conversation + { + ConversationId = Guid.NewGuid().ToString(), + LastModifiedUnixTime = _unixTimeNow + }); + conversations.Add(new Conversation + { + ConversationId = Guid.NewGuid().ToString(), + LastModifiedUnixTime = _unixTimeNow + }); + + var userConversationServiceResult = new GetUserConversationsServiceResult + { + Conversations = conversations, + NextContinuationToken = _nextContinuationToken + }; + + _userConversationServiceMock.Setup(m => m.GetUserConversations(_username, 10, OrderBy.DESC, null, 0)) + .ReturnsAsync(userConversationServiceResult); + + string nextUri = "/api/conversations" + + $"?username={_username}" + + "&limit=10" + + "&lastSeenConversationTime=0" + + $"&continuationToken={_nextContinuationToken}"; + + var response = await _httpClient.GetAsync($"api/Conversations/?username={_username}"); + var json = await response.Content.ReadAsStringAsync(); + var receivedGetUserConversationsResponse = JsonConvert.DeserializeObject(json); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(conversations, receivedGetUserConversationsResponse.Conversations); + Assert.Equal(nextUri, receivedGetUserConversationsResponse.NextUri); + } + + [Fact] + public async Task GetUserConversations_InvalidArguments() + { + _userConversationServiceMock.Setup(m => m.GetUserConversations(_username, 10, OrderBy.DESC, null, 0)) + .ThrowsAsync(new ArgumentException($"Invalid arguments.")); + + var response = await _httpClient.GetAsync($"api/Conversations/?username={_username}"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GetUserConversations_InvalidContinuationToken() + { + string invalidContinuationToken = Guid.NewGuid().ToString(); + + _userConversationServiceMock.Setup(m => m.GetUserConversations( + _username, 10, OrderBy.DESC, invalidContinuationToken, 0)) + .ThrowsAsync(new InvalidContinuationTokenException($"Continuation token {invalidContinuationToken} is invalid.")); + + var response = await _httpClient.GetAsync( + $"api/Conversations/?username={_username}&continuationToken={invalidContinuationToken}"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GetUserConversations_UserNotFound() + { + _userConversationServiceMock.Setup(m => m.GetUserConversations( + _username, 10, OrderBy.DESC, null, 0)) + .ThrowsAsync(new UserNotFoundException($"User {_username} was not found.")); + + var response = await _httpClient.GetAsync( + $"api/Conversations/?username={_username}&"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task StartConversation_Success() + { + var startConversationServiceResult = new StartConversationServiceResult + { + ConversationId = Guid.NewGuid().ToString(), + CreatedUnixTime = _unixTimeNow + }; + + _userConversationServiceMock.Setup(m => m.CreateConversation(It.Is( + p => p.Participants.SequenceEqual(_startConversationRequest.Participants) + && p.FirstMessage == _startConversationRequest.FirstMessage))) + .ReturnsAsync(startConversationServiceResult); + + var expectedStartConversationResponse = new StartConversationResponse + { + ConversationId = startConversationServiceResult.ConversationId, + CreatedUnixTime = startConversationServiceResult.CreatedUnixTime + }; + + var response = await _httpClient.PostAsJsonAsync($"api/Conversations/", _startConversationRequest); + var json = await response.Content.ReadAsStringAsync(); + var receivedStartConversationResponse = JsonConvert.DeserializeObject(json); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.Equal(expectedStartConversationResponse, receivedStartConversationResponse); + } + + [Fact] + public async Task StartConversation_InvalidArguments() + { + _userConversationServiceMock.Setup(m => m.CreateConversation(It.Is( + p => p.Participants.SequenceEqual(_startConversationRequest.Participants) + && p.FirstMessage == _startConversationRequest.FirstMessage))) + .ThrowsAsync(new ArgumentException()); + + var response = await _httpClient.PostAsJsonAsync($"api/Conversations/", _startConversationRequest); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task StartConversation_ProfileNotFound() + { + _userConversationServiceMock.Setup(m => m.CreateConversation(It.Is( + p => p.Participants.SequenceEqual(_startConversationRequest.Participants) + && p.FirstMessage == _startConversationRequest.FirstMessage))) + .ThrowsAsync(new ProfileNotFoundException($"A profile with the username {_username} was not found.")); + + var response = await _httpClient.PostAsJsonAsync($"api/Conversations/", _startConversationRequest); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task StartConversation_MessageExists() + { + _userConversationServiceMock.Setup(m => m.CreateConversation(It.Is( + p => p.Participants.SequenceEqual(_startConversationRequest.Participants) + && p.FirstMessage == _startConversationRequest.FirstMessage))) + .ThrowsAsync(new MessageExistsException( + $"A message with ID {_startConversationRequest.FirstMessage.MessageId} already exists.")); + + var response = await _httpClient.PostAsJsonAsync($"api/Conversations/", _startConversationRequest); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + [Fact] + public async Task GetMessages_Success() + { + List messages = new(); + messages.Add(new Message + { + MessageId = Guid.NewGuid().ToString(), + SenderUsername = Guid.NewGuid().ToString(), + UnixTime = _unixTimeNow + }); + messages.Add(new Message + { + MessageId = Guid.NewGuid().ToString(), + SenderUsername = Guid.NewGuid().ToString(), + UnixTime = _unixTimeNow + }); + + var getMessagesServiceResult = new GetMessagesServiceResult + { + Messages = messages, + NextContinuationToken = _nextContinuationToken + }; + + _messageServiceMock.Setup(m => m.GetMessages(_conversationId, 10, OrderBy.DESC, null, 0)) + .ReturnsAsync(getMessagesServiceResult); + + string nextUri = $"/api/conversations/{_conversationId}/messages" + + "&limit=10" + + $"&continuationToken={_nextContinuationToken}" + + "&lastSeenConversationTime=0"; + + var response = await _httpClient.GetAsync($"/api/conversations/{_conversationId}/messages/"); + var json = await response.Content.ReadAsStringAsync(); + var receivedGetMessagesResponse = JsonConvert.DeserializeObject(json); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(messages, receivedGetMessagesResponse.Messages); + Assert.Equal(nextUri, receivedGetMessagesResponse.NextUri); + } + + [Fact] + public async Task GetMessages_InvalidArguments() + { + _messageServiceMock.Setup(m => m.GetMessages(_conversationId, 10, OrderBy.DESC, null, 0)) + .ThrowsAsync(new ArgumentException()); + + var response = await _httpClient.GetAsync($"/api/conversations/{_conversationId}/messages/"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GetMessages_ConversationDoesNotExist() + { + _messageServiceMock.Setup(m => m.GetMessages(_conversationId, 10, OrderBy.DESC, null, 0)) + .ThrowsAsync(new ConversationDoesNotExistException( + $"A conversation partition with the conversationId {_conversationId} does not exist.")); + + var response = await _httpClient.GetAsync($"/api/conversations/{_conversationId}/messages/"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task PostMessage_Success() + { + var sendMessageResponse = new SendMessageResponse + { + CreatedUnixTime = _unixTimeNow + }; + + _messageServiceMock.Setup(m => m.AddMessage(_conversationId, false, _sendMessageRequest)) + .ReturnsAsync(sendMessageResponse); + + var response = await _httpClient.PostAsJsonAsync( + $"/api/conversations/{_conversationId}/messages/", _sendMessageRequest); + var json = await response.Content.ReadAsStringAsync(); + var receivedSendMessageResponse = JsonConvert.DeserializeObject(json); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.Equal(sendMessageResponse, receivedSendMessageResponse); + } + + [Fact] + public async Task PostMessage_InvalidArguments() + { + _messageServiceMock.Setup(m => m.AddMessage(_conversationId, false, _sendMessageRequest)) + .ThrowsAsync(new ArgumentException()); + + var response = await _httpClient.PostAsJsonAsync( + $"/api/conversations/{_conversationId}/messages/", _sendMessageRequest); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PostMessage_UserNotParticipant() + { + _messageServiceMock.Setup(m => m.AddMessage(_conversationId, false, _sendMessageRequest)) + .ThrowsAsync(new UserNotParticipantException( + $"User {_username} is not a participant of conversation {_conversationId}.")); + + var response = await _httpClient.PostAsJsonAsync( + $"/api/conversations/{_conversationId}/messages/", _sendMessageRequest); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task PostMessage_ProfileNotFound() + { + _messageServiceMock.Setup(m => m.AddMessage(_conversationId, false, _sendMessageRequest)) + .ThrowsAsync(new ProfileNotFoundException( + $"A profile with the username {_username} was not found.")); + + var response = await _httpClient.PostAsJsonAsync( + $"/api/conversations/{_conversationId}/messages/", _sendMessageRequest); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task PostMessage_ConversationDoesNotExist() + { + _messageServiceMock.Setup(m => m.AddMessage(_conversationId, false, _sendMessageRequest)) + .ThrowsAsync(new ConversationDoesNotExistException( + $"A conversation partition with the conversationId {_conversationId} does not exist.")); + + var response = await _httpClient.PostAsJsonAsync( + $"/api/conversations/{_conversationId}/messages/", _sendMessageRequest); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task PostMessage_MessageExists() + { + _messageServiceMock.Setup(m => m.AddMessage(_conversationId, false, _sendMessageRequest)) + .ThrowsAsync(new MessageExistsException($"A message with ID {_sendMessageRequest.MessageId} already exists.")); + + var response = await _httpClient.PostAsJsonAsync( + $"/api/conversations/{_conversationId}/messages/", _sendMessageRequest); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } +} \ No newline at end of file diff --git a/ChatService.Web.Tests/Controllers/ImagesControllerTests.cs b/ChatService.Web.Tests/Controllers/ImagesControllerTests.cs new file mode 100644 index 0000000..5ae893a --- /dev/null +++ b/ChatService.Web.Tests/Controllers/ImagesControllerTests.cs @@ -0,0 +1,125 @@ +using System.Net; +using System.Net.Http.Headers; +using ChatService.Web.Dtos; +using ChatService.Web.Exceptions; +using ChatService.Web.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Newtonsoft.Json; + +namespace ChatService.Web.Tests.Controllers; + +public class ImagesControllerTests : IClassFixture> +{ + private readonly Mock _imageServiceMock = new(); + private readonly HttpClient _httpClient; + + private static readonly Image _image = new("image/jpeg", new MemoryStream()); + + private readonly MultipartFormDataContent _content = new(); + + private readonly StreamContent _fileContent = new StreamContent(_image.Content) + { + Headers = { ContentType = new MediaTypeHeaderValue(_image.ContentType) } + }; + + private readonly string _imageId = Guid.NewGuid().ToString(); + + public ImagesControllerTests(WebApplicationFactory factory) + { + _httpClient = factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => { services.AddSingleton(_imageServiceMock.Object); }); + }).CreateClient(); + } + + [Fact] + public async Task UploadImage_Success() + { + var uploadImageResponse = new UploadImageResponse(_imageId); + + _imageServiceMock.Setup(m => m.UploadImage(It.IsAny())) + .ReturnsAsync(new UploadImageServiceResult(_imageId)); + + _content.Add(_fileContent,"File", "image.jpeg"); + + var response = await _httpClient.PostAsync("api/Images/", _content); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + Assert.Equal($"http://localhost/api/Images/{_imageId}", response.Headers.GetValues("Location").First()); + + var json = await response.Content.ReadAsStringAsync(); + var receivedUploadImageResponse = JsonConvert.DeserializeObject(json); + + Assert.Equal(uploadImageResponse, receivedUploadImageResponse); + } + + [Fact] + public async Task UploadImage_MissingFile() + { + var response = await _httpClient.PostAsync("api/Images/", _content); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + _imageServiceMock.Verify( m => m.UploadImage(It.IsAny()), Times.Never); + } + + [Fact] + public async Task UploadImage_InvalidImageType() + { + _imageServiceMock.Setup(m => m.UploadImage(It.IsAny())) + .ThrowsAsync(new InvalidImageTypeException($"Invalid image type {_image.ContentType}.")); + + _content.Add(_fileContent,"File", "text/plain"); + + var response = await _httpClient.PostAsync("api/Images/", _content); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task DownloadImage_Success() + { + var fileContentResult = new FileContentResult(_image.Content.ToArray(), _image.ContentType); + + _imageServiceMock.Setup(m => m.DownloadImage(_imageId)) + .ReturnsAsync(fileContentResult); + + var response = await _httpClient.GetAsync($"api/Images/{_imageId}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content.Headers.ContentType); + + var contentType = response.Content.Headers.ContentType.ToString(); + var content = await response.Content.ReadAsByteArrayAsync(); + + Assert.Equal(fileContentResult.FileContents, content); + Assert.Equal(fileContentResult.ContentType, contentType); + } + + [Fact] + public async Task DownloadImage_NotFound() + { + _imageServiceMock.Setup(m => m.DownloadImage(_imageId)) + .ThrowsAsync( new ImageNotFoundException($"An image with id {_imageId} was not found.")); + + var response = await _httpClient.GetAsync($"api/Images/{_imageId}"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task DownloadImage_InvalidArgument() + { + _imageServiceMock.Setup(m => m.DownloadImage(_imageId)) + .ThrowsAsync( new ArgumentException("Invalid imageId")); + + var response = await _httpClient.GetAsync($"api/Images/{_imageId}"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } +} \ No newline at end of file diff --git a/ChatService.Web.Tests/Controllers/ProfilesControllerTests.cs b/ChatService.Web.Tests/Controllers/ProfilesControllerTests.cs new file mode 100644 index 0000000..33f78a1 --- /dev/null +++ b/ChatService.Web.Tests/Controllers/ProfilesControllerTests.cs @@ -0,0 +1,154 @@ +using System.Net; +using System.Net.Http.Json; +using ChatService.Web.Dtos; +using ChatService.Web.Exceptions; +using ChatService.Web.Services; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Newtonsoft.Json; + +namespace ChatService.Web.Tests.Controllers; + +public class ProfilesControllerTests : IClassFixture> +{ + private readonly Mock _profileServiceMock = new(); + private readonly HttpClient _httpClient; + private readonly Profile _profile = new Profile + { + Username = "foobar", + FirstName = "Foo", + LastName = "Bar", + ProfilePictureId = "123" + }; + + public ProfilesControllerTests(WebApplicationFactory factory) + { + _httpClient = factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddSingleton(_profileServiceMock.Object); + }); + }).CreateClient(); + } + + [Fact] + public async Task GetProfile_Success() + { + _profileServiceMock.Setup(m => m.GetProfile(_profile.Username)) + .ReturnsAsync(_profile); + + var response = await _httpClient.GetAsync($"api/Profiles/{_profile.Username}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var json = await response.Content.ReadAsStringAsync(); + var receivedProfile = JsonConvert.DeserializeObject(json); + Assert.Equal(_profile, receivedProfile); + } + + [Fact] + public async Task GetProfile_ProfileNotFound() + { + _profileServiceMock.Setup(m => m.GetProfile(_profile.Username)) + .ThrowsAsync(new ProfileNotFoundException($"A profile with the username {_profile.Username} was not found.")); + + var response = await _httpClient.GetAsync($"api/Profiles/{_profile.Username}"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + + var json = await response.Content.ReadAsStringAsync(); + Assert.Equal($"A profile with the username {_profile.Username} was not found.", json); + } + + [Fact] + public async Task PostProfile_Success() + { + var response = await _httpClient.PostAsJsonAsync("api/Profiles/", _profile); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + Assert.Equal($"http://localhost/api/Profiles/{_profile.Username}", + response.Headers.GetValues("Location").First()); + + var json = await response.Content.ReadAsStringAsync(); + var receivedProfile = JsonConvert.DeserializeObject(json); + Assert.Equal(_profile, receivedProfile); + + _profileServiceMock.Verify(mock => mock.AddProfile(_profile), Times.Once); + } + + [Fact] + public async Task PostProfile_UsernameTaken() + { + _profileServiceMock.Setup(m => m.AddProfile(_profile)) + .ThrowsAsync(new UsernameTakenException($"The username {_profile.Username} is taken.")); + + var response = await _httpClient.PostAsJsonAsync("api/Profiles/", _profile); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + [Theory] + [InlineData(null, "Foo", "Bar", "123")] + [InlineData("", "Foo", "Bar", "123")] + [InlineData(" ", "Foo", "Bar", "123")] + [InlineData("foobar", null, "Bar", "123")] + [InlineData("foobar", "", "Bar", "123")] + [InlineData("foobar", " ", "Bar", "123")] + [InlineData("foobar", "Foo", null, "123")] + [InlineData("foobar", "Foo", "", "123")] + [InlineData("foobar", "Foo", " ", "123")] + [InlineData("foobar", "Foo", "Bar", null)] + [InlineData("foobar", "Foo", "Bar", "")] + [InlineData("foobar", "Foo", "Bar", " ")] + public async Task PostProfile_InvalidArguments(string username, string firstName, string lastName, string profilePictureId) + { + _profileServiceMock.Setup(m => m.AddProfile(_profile)) + .ThrowsAsync(new ArgumentException($"Invalid profile {_profile}")); + + Profile profile = new Profile + { + Username = username, + FirstName = firstName, + LastName = lastName, + ProfilePictureId = profilePictureId + }; + + var response = await _httpClient.PostAsJsonAsync("api/Profiles/", profile); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PostProfile_InvalidUsername() + { + Profile profile = new Profile + { + Username = "username_with_underscore", + FirstName = "firstName", + LastName = "lastName", + ProfilePictureId = "profilePictureId" + }; + + _profileServiceMock.Setup(m => m.AddProfile(profile)) + .ThrowsAsync(new InvalidUsernameException($"Username {profile.Username} is invalid. Usernames cannot have an underscore.")); + + var response = await _httpClient.PostAsJsonAsync("api/Profiles/", profile); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task ProfileProfile_ProfilePictureNotFound() + { + _profileServiceMock.Setup(m => m.AddProfile(_profile)) + .ThrowsAsync(new ImageNotFoundException("Invalid profile picture ID.")); + + var response = await _httpClient.PostAsJsonAsync("api/Profiles/", _profile); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } +} \ No newline at end of file diff --git a/ChatService.Web.Tests/Services/ImageServiceTests.cs b/ChatService.Web.Tests/Services/ImageServiceTests.cs new file mode 100644 index 0000000..4c14729 --- /dev/null +++ b/ChatService.Web.Tests/Services/ImageServiceTests.cs @@ -0,0 +1,87 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Exceptions; +using ChatService.Web.Services; +using ChatService.Web.Storage; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace ChatService.Web.Tests.Services; + +public class ImageServiceTests : IClassFixture> +{ + private readonly Mock _imageStoreMock = new(); + private readonly IImageService _imageService; + + private readonly string _imageId = Guid.NewGuid().ToString(); + private readonly Image _image = new("image/jpeg", new MemoryStream(new byte[] { 0x01, 0x02, 0x03 })); + + public ImageServiceTests(WebApplicationFactory factory) + { + _imageService = factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddSingleton(_imageStoreMock.Object); + }); + }).Services.GetRequiredService(); + } + + [Fact] + public async Task UploadImage_Success() + { + _imageStoreMock.Setup(m => m.UploadImage(It.IsAny())) + .ReturnsAsync(_imageId); + + var expectedUploadImageServiceResult = new UploadImageServiceResult(_imageId); + + var receivedUploadImageServiceResult = await _imageService.UploadImage(_image); + + Assert.Equal(expectedUploadImageServiceResult, receivedUploadImageServiceResult); + } + + [Fact] + public async Task UploadImage_InvalidImageType() + { + var invalidImage = new Image("text/plain", new MemoryStream()); + + await Assert.ThrowsAsync(() => _imageService.UploadImage(invalidImage)); + + _imageStoreMock.Verify(m => m.UploadImage(It.IsAny()), Times.Never); + } + + [Fact] + public async Task DownloadImage_Success() + { + var expectedFileContentResult = new FileContentResult(_image.Content.ToArray(), _image.ContentType); + + _imageStoreMock.Setup(m => m.DownloadImage(_imageId)) + .ReturnsAsync(_image); + + var receivedFileContentResult = await _imageService.DownloadImage(_imageId); + + Assert.Equal(expectedFileContentResult.ContentType, receivedFileContentResult.ContentType); + Assert.True(expectedFileContentResult.FileContents.SequenceEqual(receivedFileContentResult.FileContents)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task DownloadImage_InvalidArguments(string imageId) + { + + await Assert.ThrowsAsync(() => _imageService.DownloadImage(imageId)); + } + + [Fact] + public async Task DownloadImage_ImageNotFound() + { + _imageStoreMock.Setup(m => m.DownloadImage(_imageId)) + .ReturnsAsync((Image?)null); + + await Assert.ThrowsAsync(() => _imageService.DownloadImage(_imageId)); + } +} \ No newline at end of file diff --git a/ChatService.Web.Tests/Services/MessageServiceTests.cs b/ChatService.Web.Tests/Services/MessageServiceTests.cs new file mode 100644 index 0000000..7a13f7d --- /dev/null +++ b/ChatService.Web.Tests/Services/MessageServiceTests.cs @@ -0,0 +1,212 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Enums; +using ChatService.Web.Exceptions; +using ChatService.Web.Services; +using ChatService.Web.Storage; +using ChatService.Web.Utilities; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace ChatService.Web.Tests.Services; + +public class MessageServiceTests : IClassFixture> +{ + private readonly Mock _messageStoreMock = new(); + private readonly Mock _profileServiceMock = new(); + private readonly IMessageService _messageService; + + private static readonly string _senderUsername = Guid.NewGuid().ToString(); + + private static readonly string _recipientUsername = Guid.NewGuid().ToString(); + + private static readonly string _conversationId = ConversationIdUtilities.GenerateConversationId(_senderUsername, _recipientUsername); + + private readonly long _unixTimeNow = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + private readonly SendMessageRequest _sendMessageRequest = new SendMessageRequest + { + MessageId = Guid.NewGuid().ToString(), + SenderUsername = _senderUsername, + Text = "Hello" + }; + + public MessageServiceTests(WebApplicationFactory factory) + { + _messageService = factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddSingleton(_messageStoreMock.Object); + services.AddSingleton(_profileServiceMock.Object); + }); + }).Services.GetRequiredService(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task AddMessage_Success(bool isFirstMessage) + { + _profileServiceMock.Setup(m => m.ProfileExists(_senderUsername)) + .ReturnsAsync(true); + + _messageStoreMock.Setup(m => m.ConversationPartitionExists(_conversationId)) + .ReturnsAsync(true); + + Message message = new Message + { + MessageId = _sendMessageRequest.MessageId, + UnixTime = _unixTimeNow, + SenderUsername = _sendMessageRequest.SenderUsername, + Text = _sendMessageRequest.Text + }; + + SendMessageResponse expectedSendMessageResponse = new SendMessageResponse + { + CreatedUnixTime = _unixTimeNow + }; + + SendMessageResponse receivedSendMessageResponse = await _messageService.AddMessage( + _conversationId, isFirstMessage, _sendMessageRequest); + + _messageStoreMock.Verify(m => m.AddMessage(_conversationId, It.Is( + m => m.MessageId == message.MessageId + && m.SenderUsername == message.SenderUsername + && m.Text == message.Text)), Times.Once); + + receivedSendMessageResponse.CreatedUnixTime = _unixTimeNow; + + Assert.Equal(expectedSendMessageResponse, receivedSendMessageResponse); + } + + [Theory] + [InlineData(null, "messageId", "senderUsername", "text")] + [InlineData("", "messageId", "senderUsername", "text")] + [InlineData(" ", "messageId", "senderUsername", "text")] + [InlineData("conversationId", null, "senderUsername", "text")] + [InlineData("conversationId", "", "senderUsername", "text")] + [InlineData("conversationId", " ", "senderUsername", "text")] + [InlineData("conversationId", "messageId", null, "text")] + [InlineData("conversationId", "messageId", "", "text")] + [InlineData("conversationId", "messageId", " ", "text")] + [InlineData("conversationId", "messageId", "senderUsername", null)] + [InlineData("conversationId", "messageId", "senderUsername", "")] + [InlineData("conversationId", "messageId", "senderUsername", " ")] + public async Task AddMessage_InvalidArguments( + string conversationId, string messageId, string senderUsername, string text) + { + SendMessageRequest sendMessageRequest = new SendMessageRequest + { + MessageId = messageId, + SenderUsername = senderUsername, + Text = text + }; + + await Assert.ThrowsAsync(() => _messageService.AddMessage( + conversationId, true, sendMessageRequest)); + } + + [Fact] + public async Task AddMessage_UserNotParticipant() + { + _sendMessageRequest.SenderUsername = Guid.NewGuid().ToString(); + + _messageStoreMock.Setup(m => m.ConversationPartitionExists(_conversationId)) + .ReturnsAsync(true); + + _profileServiceMock.Setup(m => m.ProfileExists(_sendMessageRequest.SenderUsername)) + .ReturnsAsync(true); + + await Assert.ThrowsAsync( + () => _messageService.AddMessage(_conversationId, true, _sendMessageRequest)); + } + + [Fact] + public async Task AddMessage_ProfileNotFound() + { + _profileServiceMock.Setup(m => m.ProfileExists(_senderUsername)) + .ReturnsAsync(false); + + await Assert.ThrowsAsync(() => _messageService.AddMessage( + _conversationId, true, _sendMessageRequest)); + } + + [Fact] + public async Task AddMessage_ConversationDoesNotExist() + { + _profileServiceMock.Setup(m => m.ProfileExists(_senderUsername)) + .ReturnsAsync(true); + + _messageStoreMock.Setup(m => m.ConversationPartitionExists(_conversationId)) + .ReturnsAsync(false); + + await Assert.ThrowsAsync(() => _messageService.AddMessage( + _conversationId, false, _sendMessageRequest)); + } + + [Fact] + public async Task GetMessages_Success() + { + _messageStoreMock.Setup(m => m.ConversationPartitionExists(_conversationId)) + .ReturnsAsync(true); + + List messages = new List { + new Message + { + MessageId = Guid.NewGuid().ToString(), + UnixTime = _unixTimeNow, + SenderUsername = _senderUsername, + Text = "Hello" + }, + new Message + { + MessageId = Guid.NewGuid().ToString(), + UnixTime = _unixTimeNow, + SenderUsername = _senderUsername, + Text = "Good Bye" + } + }; + + string nextContinuationToken = Guid.NewGuid().ToString(); + + _messageStoreMock.Setup(m => m.GetMessages(_conversationId, 10, OrderBy.DESC, null, 0)) + .ReturnsAsync((messages, nextContinuationToken)); + + GetMessagesServiceResult expectedGetMessagesServiceResult = new GetMessagesServiceResult + { + Messages = messages, + NextContinuationToken = nextContinuationToken + }; + + GetMessagesServiceResult receivedGetMessagesServiceResult = await _messageService.GetMessages( + _conversationId, 10, OrderBy.DESC, null, 0); + + Assert.Equal(expectedGetMessagesServiceResult.Messages, receivedGetMessagesServiceResult.Messages); + Assert.Equal(expectedGetMessagesServiceResult.NextContinuationToken, receivedGetMessagesServiceResult.NextContinuationToken); + } + + [Theory] + [InlineData(null, 1, 1)] + [InlineData("", 1, 1)] + [InlineData(" ", 1, 1)] + [InlineData("conversationId", 0, 1)] + [InlineData("conversationId", -1, 1)] + [InlineData("conversationId", 1, -1)] + public async Task GetMessages_InvalidArguments(string conversationId, int limit, long lastSeenConversationTime) + { + await Assert.ThrowsAsync(() => _messageService.GetMessages(conversationId, limit, + OrderBy.DESC, null, lastSeenConversationTime)); + } + + [Fact] + public async Task GetMessages_ConversationDoesNotExist() + { + _messageStoreMock.Setup(m => m.ConversationPartitionExists(_conversationId)) + .ReturnsAsync(false); + + await Assert.ThrowsAsync(() => _messageService.GetMessages( + _conversationId, 1, OrderBy.DESC, null, 0)); + } +} \ No newline at end of file diff --git a/ChatService.Web.Tests/Services/ProfileServiceTests.cs b/ChatService.Web.Tests/Services/ProfileServiceTests.cs new file mode 100644 index 0000000..e7d589a --- /dev/null +++ b/ChatService.Web.Tests/Services/ProfileServiceTests.cs @@ -0,0 +1,158 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Exceptions; +using ChatService.Web.Services; +using ChatService.Web.Storage; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace ChatService.Web.Tests.Services; + +public class ProfileServiceTests : IClassFixture> +{ + private readonly Mock _profileStoreMock = new(); + private readonly Mock _imageStoreMock = new(); + private readonly IProfileService _profileService; + + private readonly Profile _profile = new Profile + { + Username = "foobar", + FirstName = "Foo", + LastName = "Bar", + ProfilePictureId = "123" + }; + + public ProfileServiceTests(WebApplicationFactory factory) + { + _profileService = factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddSingleton(_profileStoreMock.Object); + services.AddSingleton(_imageStoreMock.Object); + }); + }).Services.GetRequiredService(); + } + + [Fact] + public async Task GetProfile_Success() + { + _profileStoreMock.Setup(m => m.GetProfile(_profile.Username)) + .ReturnsAsync(_profile); + + var receivedProfile = await _profileService.GetProfile(_profile.Username); + + Assert.Equal(_profile, receivedProfile); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task GetProfile_InvalidArguments(string username) + { + Assert.ThrowsAsync(() => _profileService.GetProfile(username)); + } + + [Fact] + public async Task AddNewProfile_Success() + { + _profileStoreMock.Setup(m => m.ProfileExists(_profile.Username)) + .ReturnsAsync(false); + _imageStoreMock.Setup(m => m.ImageExists(_profile.ProfilePictureId)) + .ReturnsAsync(true); + + await _profileService.AddProfile(_profile); + + _profileStoreMock.Verify(m => m.AddProfile(_profile), Times.Once); + } + + [Fact] + public async Task AddNewProfile_NullProfile() + { + await Assert.ThrowsAsync( async () => await _profileService.AddProfile(null)); + } + + [Theory] + [InlineData(null, "Foo", "Bar", "dummy_id")] + [InlineData("", "Foo", "Bar", "dummy_id")] + [InlineData(" ", "Foo", "Bar", "dummy_id")] + [InlineData("foobar", null, "Bar", "dummy_id")] + [InlineData("foobar", "", "Bar", "dummy_id")] + [InlineData("foobar", " ", "Bar", "dummy_id")] + [InlineData("foobar", "Foo", null, "dummy_id")] + [InlineData("foobar", "Foo", "", "dummy_id")] + [InlineData("foobar", "Foo", " ", "dummy_id")] + [InlineData("foobar", "Foo", "Bar", null)] + [InlineData("foobar", "Foo", "Bar","")] + [InlineData("foobar", "Foo", "Bar"," ")] + public async Task AddNewProfile_InvalidArgs(string username, string firstName, string lastName, string profilePictureId) + { + Profile profile = new Profile + { + Username = username, + FirstName = firstName, + LastName = lastName, + ProfilePictureId = profilePictureId + }; + + await Assert.ThrowsAsync( async () => await _profileService.AddProfile(profile)); + } + + [Fact] + public async Task AddNewProfile_UsernameTaken() + { + _imageStoreMock.Setup(m => m.ImageExists(_profile.ProfilePictureId)) + .ReturnsAsync(true); + _profileStoreMock.Setup(m => m.AddProfile(_profile)) + .ThrowsAsync(new UsernameTakenException($"A profile with username {_profile.Username} already exists.")); + + await Assert.ThrowsAsync( async () => await _profileService.AddProfile(_profile)); + } + + [Fact] + public async Task AddNewProfile_InvalidUsername() + { + Profile profile = new Profile + { + Username = "username_with_underscore", + FirstName = "firstName", + LastName = "lastName", + ProfilePictureId = "profilePictureId" + }; + + await Assert.ThrowsAsync( async () => await _profileService.AddProfile(profile)); + } + + [Fact] + public async Task AddNewProfile_ProfilePictureNotFound() + { + _imageStoreMock.Setup(m => m.ImageExists(_profile.ProfilePictureId)) + .ReturnsAsync(false); + + await Assert.ThrowsAsync( async () => await _profileService.AddProfile(_profile)); + } + + [Fact] + public async Task DeleteProfile_Success() + { + _profileStoreMock.Setup(m => m.GetProfile(_profile.Username)) + .ReturnsAsync(_profile); + + await _profileService.DeleteProfile(_profile.Username); + + _imageStoreMock.Verify(m => m.DeleteImage(_profile.ProfilePictureId), Times.Once); + _profileStoreMock.Verify(m => m.DeleteProfile(_profile.Username), Times.Once); + } + + [Fact] + public async Task DeleteProfile_ProfileNotFound() + { + await Assert.ThrowsAsync( + async () => await _profileService.DeleteProfile(_profile.Username)); + + _imageStoreMock.Verify(m => m.DeleteImage(_profile.ProfilePictureId), Times.Never); + _profileStoreMock.Verify(m => m.DeleteProfile(_profile.Username), Times.Never); + } +} \ No newline at end of file diff --git a/ChatService.Web.Tests/Services/UserConversationServiceTests.cs b/ChatService.Web.Tests/Services/UserConversationServiceTests.cs new file mode 100644 index 0000000..77cae17 --- /dev/null +++ b/ChatService.Web.Tests/Services/UserConversationServiceTests.cs @@ -0,0 +1,268 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Enums; +using ChatService.Web.Exceptions; +using ChatService.Web.Services; +using ChatService.Web.Storage; +using ChatService.Web.Utilities; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace ChatService.Web.Tests.Services; + +public class UserConversationServiceTests : IClassFixture> +{ + private readonly Mock _messageServiceMock = new(); + private readonly Mock _userConversationStoreMock = new(); + private readonly Mock _profileServiceMock = new(); + + private readonly IUserConversationService _userConversationService; + + private static readonly long _unixTimeNow = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + private static readonly List _participants = new List + { + Guid.NewGuid().ToString(), + Guid.NewGuid().ToString() + }; + + private static readonly SendMessageRequest _sendMessageRequest = new SendMessageRequest + { + MessageId = Guid.NewGuid().ToString(), + SenderUsername = _participants.ElementAt(0), + Text = "Hello World." + }; + + private readonly StartConversationRequest _startConversationRequest = new StartConversationRequest + { + Participants = _participants, + FirstMessage = _sendMessageRequest + }; + + public UserConversationServiceTests(WebApplicationFactory factory) + { + _userConversationService = factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddSingleton(_messageServiceMock.Object); + services.AddSingleton(_userConversationStoreMock.Object); + services.AddSingleton(_profileServiceMock.Object); + }); + }).Services.GetRequiredService(); + } + + [Fact] + public async Task CreateConversation_Success() + { + _profileServiceMock.Setup(m => m.ProfileExists(_participants.ElementAt(0))) + .ReturnsAsync(true); + + _profileServiceMock.Setup(m => m.ProfileExists(_participants.ElementAt(1))) + .ReturnsAsync(true); + + var response = await _userConversationService.CreateConversation(_startConversationRequest); + + response.CreatedUnixTime = _unixTimeNow; + + StartConversationServiceResult expected = new StartConversationServiceResult + { + ConversationId = ConversationIdUtilities.GenerateConversationId( + _participants.ElementAt(0), _participants.ElementAt(1)), + CreatedUnixTime = _unixTimeNow + }; + + Assert.Equal(expected, response); + } + + [Theory] + [MemberData(nameof(GenerateInvalidParticipantsList))] + public async Task CreateConversation_InvalidParticipantsList(List participants) + { + StartConversationRequest startConversationRequest = new StartConversationRequest + { + Participants = participants, + FirstMessage = _sendMessageRequest + }; + + await Assert.ThrowsAsync( () => + _userConversationService.CreateConversation(startConversationRequest)); + } + + [Theory] + [InlineData(null, "senderUsername", "Hello world.")] + [InlineData("", "senderUsername", "Hello world.")] + [InlineData(" ", "senderUsername", "Hello world.")] + [InlineData("messageId", null, "Hello world.")] + [InlineData("messageId", "", "Hello world.")] + [InlineData("messageId", " ", "Hello world.")] + [InlineData("messageId", "senderUsername", null)] + [InlineData("messageId", "senderUsername", "")] + [InlineData("messageId", "senderUsername", " ")] + public async Task CreateConversation_InvalidSendMessageRequest(string messageId, string senderUsername, string text) + { + SendMessageRequest sendMessageRequest = new SendMessageRequest + { + MessageId = messageId, + SenderUsername = senderUsername, + Text = text + }; + + _startConversationRequest.FirstMessage = sendMessageRequest; + + await Assert.ThrowsAsync( () => + _userConversationService.CreateConversation(_startConversationRequest)); + } + + [Fact] + public async Task CreateConversation_Profile1NotFound() + { + _profileServiceMock.Setup(m => m.ProfileExists(_participants.ElementAt(0))) + .ReturnsAsync(false); + + _profileServiceMock.Setup(m => m.ProfileExists(_participants.ElementAt(1))) + .ReturnsAsync(true); + + await Assert.ThrowsAsync( () => + _userConversationService.CreateConversation(_startConversationRequest)); + } + + [Fact] + public async Task CreateConversation_Profile2NotFound() + { + _profileServiceMock.Setup(m => m.ProfileExists(_participants.ElementAt(0))) + .ReturnsAsync(true); + + _profileServiceMock.Setup(m => m.ProfileExists(_participants.ElementAt(1))) + .ReturnsAsync(false); + + await Assert.ThrowsAsync( () => + _userConversationService.CreateConversation(_startConversationRequest)); + } + + [Fact] + public async Task GetUserConversations_Success() + { + string username1 = Guid.NewGuid().ToString(); + string username2 = Guid.NewGuid().ToString(); + string username3 = Guid.NewGuid().ToString(); + + _profileServiceMock.Setup(m => m.ProfileExists(username1)) + .ReturnsAsync(true); + + Profile profile2 = new Profile + { + Username = username2, + FirstName = Guid.NewGuid().ToString(), + LastName = Guid.NewGuid().ToString(), + ProfilePictureId = Guid.NewGuid().ToString() + }; + Profile profile3 = new Profile + { + Username = username3, + FirstName = Guid.NewGuid().ToString(), + LastName = Guid.NewGuid().ToString(), + ProfilePictureId = Guid.NewGuid().ToString() + }; + + List userConversations = new List + { + new UserConversation + { + Username = username1, + ConversationId = ConversationIdUtilities.GenerateConversationId(username1, username2), + LastModifiedTime = _unixTimeNow + }, + new UserConversation + { + Username = username1, + ConversationId = ConversationIdUtilities.GenerateConversationId(username1, username3), + LastModifiedTime = _unixTimeNow + } + }; + + string nextContinuationToken = Guid.NewGuid().ToString(); + + _userConversationStoreMock.Setup(m => m.GetUserConversations( + username1, 10, OrderBy.DESC, null, 0)) + .ReturnsAsync((userConversations, nextContinuationToken)); + + _profileServiceMock.Setup(m => m.GetProfile(username2)) + .ReturnsAsync(profile2); + + _profileServiceMock.Setup(m => m.GetProfile(username3)) + .ReturnsAsync(profile3); + + List conversations = new List + { + new Conversation + { + ConversationId = ConversationIdUtilities.GenerateConversationId(username1, username2), + LastModifiedUnixTime = _unixTimeNow, + Recipient = profile2 + }, + new Conversation + { + ConversationId = ConversationIdUtilities.GenerateConversationId(username1, username3), + LastModifiedUnixTime = _unixTimeNow, + Recipient = profile3 + } + }; + + GetUserConversationsServiceResult expected = new GetUserConversationsServiceResult + { + Conversations = conversations, + NextContinuationToken = nextContinuationToken + }; + + var response = await _userConversationService.GetUserConversations( + username1, 10, OrderBy.DESC, null, 0); + + Assert.Equal(expected.Conversations, response.Conversations); + Assert.Equal(expected.NextContinuationToken, response.NextContinuationToken); + } + + [Theory] + [InlineData(null, 10, OrderBy.DESC, null, 0)] + [InlineData("", 10, OrderBy.DESC, null, 0)] + [InlineData(" ", 10, OrderBy.DESC, null, 0)] + [InlineData("username", 0, OrderBy.DESC, null, 0)] + [InlineData("username", -1, OrderBy.DESC, null, 0)] + [InlineData("username", 10, OrderBy.DESC, null, -1)] + public async Task GetUserConversations_InvalidArguments( + string username, int limit, OrderBy orderBy, string? continuationToken, long lastSeenConversationTime) + { + await Assert.ThrowsAsync( () => + _userConversationService.GetUserConversations( + username, limit, orderBy, continuationToken, lastSeenConversationTime)); + } + + [Fact] + public async Task GetUserConversations_UserNotFound() + { + _profileServiceMock.Setup(m => m.ProfileExists(_participants.ElementAt(0))) + .ReturnsAsync(false); + + await Assert.ThrowsAsync( () => + _userConversationService.GetUserConversations( + _participants.ElementAt(0), 10, OrderBy.DESC, null, 0)); + } + + public static IEnumerable GenerateInvalidParticipantsList(){ + + yield return new object[] { new List {_participants.ElementAt(0), ""} }; + yield return new object[] { new List {_participants.ElementAt(0), " "} }; + + yield return new object[] { new List { "", _participants.ElementAt(1) } }; + yield return new object[] { new List { " ", _participants.ElementAt(1) } }; + + yield return new object[] { new List + { + _participants.ElementAt(0), + _participants.ElementAt(0) + } }; + + yield return new object[] { new List { _participants.ElementAt(0) } }; + } +} \ No newline at end of file diff --git a/ChatService.Web.Tests/Usings.cs b/ChatService.Web.Tests/Usings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/ChatService.Web.Tests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/ChatService.Web/ChatService.Web.csproj b/ChatService.Web/ChatService.Web.csproj new file mode 100644 index 0000000..339ae2e --- /dev/null +++ b/ChatService.Web/ChatService.Web.csproj @@ -0,0 +1,17 @@ + + + + net6.0 + enable + enable + + + + + + + + + + + diff --git a/ChatService.Web/Configuration/BlobSettings.cs b/ChatService.Web/Configuration/BlobSettings.cs new file mode 100644 index 0000000..cb23532 --- /dev/null +++ b/ChatService.Web/Configuration/BlobSettings.cs @@ -0,0 +1,6 @@ +namespace ChatService.Web.Configuration; + +public record BlobSettings +{ + public string ConnectionString { get; init; } +} \ No newline at end of file diff --git a/ChatService.Web/Configuration/CosmosSettings.cs b/ChatService.Web/Configuration/CosmosSettings.cs new file mode 100644 index 0000000..6b03fed --- /dev/null +++ b/ChatService.Web/Configuration/CosmosSettings.cs @@ -0,0 +1,6 @@ +namespace ChatService.Web.Configuration; + +public record CosmosSettings +{ + public string ConnectionString { get; init; } +} \ No newline at end of file diff --git a/ChatService.Web/Controllers/ConversationsController.cs b/ChatService.Web/Controllers/ConversationsController.cs new file mode 100644 index 0000000..8897b5d --- /dev/null +++ b/ChatService.Web/Controllers/ConversationsController.cs @@ -0,0 +1,201 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Enums; +using ChatService.Web.Exceptions; +using ChatService.Web.Services; +using Microsoft.AspNetCore.Mvc; + +namespace ChatService.Web.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class ConversationsController : ControllerBase +{ + private readonly IUserConversationService _userConversationService; + private readonly IMessageService _messageService; + private readonly ILogger _logger; + + + public ConversationsController( + IUserConversationService userConversationService, + IMessageService messageService, + ILogger logger + ) + { + _userConversationService = userConversationService; + _messageService = messageService; + _logger = logger; + } + + [HttpGet] + public async Task> GetUserConversations(string username, + int limit = 10, OrderBy orderBy = OrderBy.DESC, string? continuationToken = null, long lastSeenConversationTime = 0){ + + using (_logger.BeginScope("{Username}", username)) + { + try + { + GetUserConversationsServiceResult result = await _userConversationService.GetUserConversations( + username, limit, orderBy, continuationToken, lastSeenConversationTime); + + _logger.LogInformation("Fetched conversations of user {Username}", username); + + string nextUri = ""; + if (result.NextContinuationToken != null) + { + nextUri = "/api/conversations" + + $"?username={username}" + + $"&limit={limit}" + + $"&lastSeenConversationTime={lastSeenConversationTime}" + + $"&continuationToken={result.NextContinuationToken}"; + } + + GetUserConversationsResponse response = new GetUserConversationsResponse + { + Conversations = result.Conversations, + NextUri = nextUri + }; + + return Ok(response); + } + catch (Exception e) when (e is ArgumentException || e is InvalidContinuationTokenException) + { + _logger.LogError(e, "Error getting user conversations: {ErrorMessage}", e.Message); + return BadRequest(e.Message); + } + catch (UserNotFoundException e) + { + _logger.LogError(e, "Error getting user conversations: {ErrorMessage}", e.Message); + return NotFound(e.Message); + } + } + } + + [HttpPost] + public async Task> StartConversation(StartConversationRequest request) + { + using (_logger.BeginScope("{SenderUsername}", request.FirstMessage.SenderUsername)) + { + try + { + StartConversationServiceResult result = await _userConversationService.CreateConversation(request); + + _logger.LogInformation( + "Created user conversation with Id {ConversationId} for user {Username}", + result.ConversationId, request.FirstMessage.SenderUsername); + + StartConversationResponse response = new StartConversationResponse + { + ConversationId = result.ConversationId, + CreatedUnixTime = result.CreatedUnixTime + }; + + return CreatedAtAction(nameof(GetUserConversations), + new { username = request.FirstMessage.SenderUsername }, response); + } + catch (ArgumentException e) + { + _logger.LogError(e, "Error creating user conversation: {ErrorMessage}", e.Message); + return BadRequest(e.Message); + } + catch (ProfileNotFoundException e) + { + _logger.LogError(e, "Error creating user conversation: {ErrorMessage}", e.Message); + return NotFound(e.Message); + } + catch (UserNotParticipantException e) + { + _logger.LogError(e, "Error creating user conversation: {ErrorMessage}", e.Message); + return new ObjectResult(e.Message) { StatusCode = 403 }; + } + catch (Exception e) when (e is MessageExistsException || e is UserConversationExistsException) + { + _logger.LogError(e, "Error creating user conversation: {ErrorMessage}", e.Message); + return Conflict(e.Message); + } + } + } + + [HttpGet("{conversationId}/messages")] + public async Task> GetMessages(string conversationId, + int limit = 10, OrderBy orderBy = OrderBy.DESC, string? continuationToken = null, long lastSeenConversationTime = 0) + { + using (_logger.BeginScope("{ConversationId}", conversationId)) + { + try + { + GetMessagesServiceResult result = await _messageService.GetMessages( + conversationId, limit, orderBy, continuationToken, lastSeenConversationTime); + + _logger.LogInformation("Fetched messages from conversation {ConversationId}", conversationId); + + string nextUri = ""; + if (result.NextContinuationToken != null) + { + nextUri = $"/api/conversations/{conversationId}/messages" + + $"&limit={limit}" + + $"&continuationToken={result.NextContinuationToken}" + + $"&lastSeenConversationTime={lastSeenConversationTime}"; + } + + GetMessagesResponse response = new GetMessagesResponse + { + Messages = result.Messages, + NextUri = nextUri + }; + + return Ok(response); + } + catch (Exception e) when (e is ArgumentException || e is InvalidContinuationTokenException) + { + _logger.LogError(e, "Error getting messages: {ErrorMessage}", e.Message); + return BadRequest(e.Message); + } + catch (ConversationDoesNotExistException e) + { + _logger.LogError(e, "Error getting messages: {ErrorMessage}", e.Message); + return NotFound(e.Message); + } + } + } + + [HttpPost("{conversationId}/messages")] + public async Task> PostMessage(string conversationId, SendMessageRequest request) + { + using (_logger.BeginScope(new Dictionary + { + {"ConversationId", conversationId}, + {"SenderUsername", request.SenderUsername} + })) + { + try + { + SendMessageResponse response = await _messageService.AddMessage(conversationId, false, request); + + _logger.LogInformation("Adding message {MessageId} to conversation {ConversationId} by sender {SenderUsername}", + request.MessageId, conversationId, request.SenderUsername); + + return CreatedAtAction(nameof(GetMessages), new { conversationId = conversationId}, response); + } + catch (ArgumentException e) + { + _logger.LogError(e, "Error adding message: {ErrorMessage}", e.Message); + return BadRequest(e.Message); + } + catch (UserNotParticipantException e) + { + _logger.LogError(e, "Error adding message: {ErrorMessage}", e.Message); + return new ObjectResult(e.Message) { StatusCode = 403 }; + } + catch (Exception e) when (e is ProfileNotFoundException || e is ConversationDoesNotExistException) + { + _logger.LogError(e, "Error adding message: {ErrorMessage}", e.Message); + return NotFound(e.Message); + } + catch (MessageExistsException e) + { + _logger.LogError(e, "Error adding message: {ErrorMessage}", e.Message); + return Conflict(e.Message); + } + } + } +} \ No newline at end of file diff --git a/ChatService.Web/Controllers/ImagesController.cs b/ChatService.Web/Controllers/ImagesController.cs new file mode 100644 index 0000000..3a515b1 --- /dev/null +++ b/ChatService.Web/Controllers/ImagesController.cs @@ -0,0 +1,65 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Exceptions; +using ChatService.Web.Services; +using Microsoft.AspNetCore.Mvc; + +namespace ChatService.Web.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class ImagesController : ControllerBase +{ + private readonly IImageService _imageService; + private readonly ILogger _logger; + + public ImagesController(IImageService imageService, ILogger logger) + { + _imageService = imageService; + _logger = logger; + } + + [HttpPost] + public async Task> UploadImage([FromForm] UploadImageRequest request) + { + MemoryStream content = new(); + await request.File.CopyToAsync(content); + Image image = new Image(request.File.ContentType, content); + + try + { + UploadImageServiceResult result = await _imageService.UploadImage(image); + _logger.LogInformation("Uploaded image with id {id}.", result.ImageId); + return CreatedAtAction(nameof(DownloadImage), new { imageId = result.ImageId }, + new UploadImageResponse(result.ImageId)); + } + catch (InvalidImageTypeException e) + { + _logger.LogError(e, "Error uploading image: {ErrorMessage}", e.Message); + return BadRequest(e.Message); + } + } + + [HttpGet("{imageId}")] + public async Task DownloadImage(string imageId) + { + using (_logger.BeginScope("{ImageId}", imageId)) + { + try + { + var result = await _imageService.DownloadImage(imageId); + _logger.LogInformation("Downloaded image with id {id}.", imageId); + return result; + } + catch (ArgumentException e) + { + _logger.LogError(e, "Error downloading image: {ErrorMessage}", e.Message); + return BadRequest(e.Message); + } + catch (ImageNotFoundException e) + { + _logger.LogError(e, "Error downloading image: {ErrorMessage}", e.Message); + return NotFound(e.Message); + } + } + } +} \ No newline at end of file diff --git a/ChatService.Web/Controllers/ProfilesController.cs b/ChatService.Web/Controllers/ProfilesController.cs new file mode 100644 index 0000000..56e7be8 --- /dev/null +++ b/ChatService.Web/Controllers/ProfilesController.cs @@ -0,0 +1,64 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Exceptions; +using ChatService.Web.Services; +using Microsoft.AspNetCore.Mvc; + +namespace ChatService.Web.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class ProfilesController : ControllerBase +{ + private readonly IProfileService _profileService; + private readonly ILogger _logger; + + public ProfilesController(IProfileService profileService, ILogger logger) + { + _profileService = profileService; + _logger = logger; + + } + + [HttpGet("{username}")] + public async Task> GetProfile(string username) + { + using (_logger.BeginScope("{Username}", username)) + { + try + { + var profile = await _profileService.GetProfile(username); + _logger.LogInformation("Profile of {Username} fetched.", username); + return Ok(profile); + } + catch (ProfileNotFoundException e) + { + _logger.LogError(e, "Error finding profile: {ErrorMessage}", e.Message); + return NotFound(e.Message); + } + } + } + + [HttpPost] + public async Task> PostProfile(Profile profile) + { + using (_logger.BeginScope("{Profile}", profile)) + { + try + { + await _profileService.AddProfile(profile); + _logger.LogInformation("Created Profile for user {ProfileUsername}.", profile.Username); + return CreatedAtAction(nameof(GetProfile), new { username = profile.Username }, profile); + } + catch (Exception e) when (e is ArgumentException || e is ImageNotFoundException || e is InvalidUsernameException) + { + _logger.LogError(e, "Error posting profile: {ErrorMessage}", e.Message); + return BadRequest(e.Message); + } + catch (UsernameTakenException e) + { + _logger.LogError(e, "Error posting profile: {ErrorMessage}", e.Message); + return Conflict(e.Message); + } + } + } +} \ No newline at end of file diff --git a/ChatService.Web/Dtos/Conversation.cs b/ChatService.Web/Dtos/Conversation.cs new file mode 100644 index 0000000..233d3bd --- /dev/null +++ b/ChatService.Web/Dtos/Conversation.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatService.Web.Dtos; + +public record Conversation +{ + [Required] public string ConversationId { get; set; } + [Required] public long LastModifiedUnixTime { get; set; } + [Required] public Profile Recipient { get; set; } +} \ No newline at end of file diff --git a/ChatService.Web/Dtos/GetMessagesResponse.cs b/ChatService.Web/Dtos/GetMessagesResponse.cs new file mode 100644 index 0000000..e87df01 --- /dev/null +++ b/ChatService.Web/Dtos/GetMessagesResponse.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatService.Web.Dtos; + +public record GetMessagesResponse +{ + [Required] public List Messages { get; set; } + [Required] public string NextUri { get; set; } +} \ No newline at end of file diff --git a/ChatService.Web/Dtos/GetMessagesServiceResult.cs b/ChatService.Web/Dtos/GetMessagesServiceResult.cs new file mode 100644 index 0000000..46ca83f --- /dev/null +++ b/ChatService.Web/Dtos/GetMessagesServiceResult.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatService.Web.Dtos; + +public record GetMessagesServiceResult +{ + [Required] public List Messages { get; set; } + [Required] public string? NextContinuationToken { get; set; } +} \ No newline at end of file diff --git a/ChatService.Web/Dtos/GetUserConversationsResponse.cs b/ChatService.Web/Dtos/GetUserConversationsResponse.cs new file mode 100644 index 0000000..5b9d11e --- /dev/null +++ b/ChatService.Web/Dtos/GetUserConversationsResponse.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatService.Web.Dtos; + +public record GetUserConversationsResponse +{ + [Required] public List Conversations { get; set; } + [Required] public string NextUri { get; set; } +} \ No newline at end of file diff --git a/ChatService.Web/Dtos/GetUserConversationsServiceResult.cs b/ChatService.Web/Dtos/GetUserConversationsServiceResult.cs new file mode 100644 index 0000000..dae2d2c --- /dev/null +++ b/ChatService.Web/Dtos/GetUserConversationsServiceResult.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatService.Web.Dtos; + +public record GetUserConversationsServiceResult +{ + [Required] public List Conversations { get; set; } + [Required] public string? NextContinuationToken { get; set; } +} \ No newline at end of file diff --git a/ChatService.Web/Dtos/Image.cs b/ChatService.Web/Dtos/Image.cs new file mode 100644 index 0000000..5ba38a9 --- /dev/null +++ b/ChatService.Web/Dtos/Image.cs @@ -0,0 +1,5 @@ +namespace ChatService.Web.Dtos; + +public record Image( + string ContentType, + MemoryStream Content); \ No newline at end of file diff --git a/ChatService.Web/Dtos/Message.cs b/ChatService.Web/Dtos/Message.cs new file mode 100644 index 0000000..b872f94 --- /dev/null +++ b/ChatService.Web/Dtos/Message.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatService.Web.Dtos; + +public record Message +{ + [Required] public string MessageId { get; set; } + [Required] public long UnixTime { get; set; } + [Required] public string SenderUsername { get; set; } + [Required] public string Text { get; set; } +}; \ No newline at end of file diff --git a/ChatService.Web/Dtos/Profile.cs b/ChatService.Web/Dtos/Profile.cs new file mode 100644 index 0000000..7513484 --- /dev/null +++ b/ChatService.Web/Dtos/Profile.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatService.Web.Dtos; + +public record Profile +{ + [Required] public string Username { get; set; } + [Required] public string FirstName { get; set; } + [Required] public string LastName { get; set; } + [Required] public string ProfilePictureId { get; set; } +} \ No newline at end of file diff --git a/ChatService.Web/Dtos/SendMessageRequest.cs b/ChatService.Web/Dtos/SendMessageRequest.cs new file mode 100644 index 0000000..e6d8e6f --- /dev/null +++ b/ChatService.Web/Dtos/SendMessageRequest.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatService.Web.Dtos; + +public record SendMessageRequest +{ + [Required] public string MessageId { get; set; } + [Required] public string SenderUsername { get; set; } + [Required] public string Text { get; set; } +} \ No newline at end of file diff --git a/ChatService.Web/Dtos/SendMessageResponse.cs b/ChatService.Web/Dtos/SendMessageResponse.cs new file mode 100644 index 0000000..44734d0 --- /dev/null +++ b/ChatService.Web/Dtos/SendMessageResponse.cs @@ -0,0 +1,8 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatService.Web.Dtos; + +public record SendMessageResponse +{ + [Required] public long CreatedUnixTime { get; set; } +} \ No newline at end of file diff --git a/ChatService.Web/Dtos/StartConversationRequest.cs b/ChatService.Web/Dtos/StartConversationRequest.cs new file mode 100644 index 0000000..27432ec --- /dev/null +++ b/ChatService.Web/Dtos/StartConversationRequest.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatService.Web.Dtos; + +public record StartConversationRequest +{ + [Required] public List Participants { get; set; } + [Required] public SendMessageRequest FirstMessage { get; set; } +} \ No newline at end of file diff --git a/ChatService.Web/Dtos/StartConversationResponse.cs b/ChatService.Web/Dtos/StartConversationResponse.cs new file mode 100644 index 0000000..8d81daa --- /dev/null +++ b/ChatService.Web/Dtos/StartConversationResponse.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatService.Web.Dtos; + +public record StartConversationResponse +{ + [Required] public string ConversationId { get; set; } + [Required] public long CreatedUnixTime { get; set; } +} \ No newline at end of file diff --git a/ChatService.Web/Dtos/StartConversationServiceResult.cs b/ChatService.Web/Dtos/StartConversationServiceResult.cs new file mode 100644 index 0000000..bd2f1bc --- /dev/null +++ b/ChatService.Web/Dtos/StartConversationServiceResult.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatService.Web.Dtos; + +public record StartConversationServiceResult +{ + [Required] public string ConversationId { get; set; } + [Required] public long CreatedUnixTime { get; set; } +} \ No newline at end of file diff --git a/ChatService.Web/Dtos/UploadImageRequest.cs b/ChatService.Web/Dtos/UploadImageRequest.cs new file mode 100644 index 0000000..66b6670 --- /dev/null +++ b/ChatService.Web/Dtos/UploadImageRequest.cs @@ -0,0 +1,6 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatService.Web.Dtos; + +public record UploadImageRequest( + [Required] IFormFile File); \ No newline at end of file diff --git a/ChatService.Web/Dtos/UploadImageResponse.cs b/ChatService.Web/Dtos/UploadImageResponse.cs new file mode 100644 index 0000000..a95c54e --- /dev/null +++ b/ChatService.Web/Dtos/UploadImageResponse.cs @@ -0,0 +1,4 @@ +namespace ChatService.Web.Dtos; + +public record UploadImageResponse( + string ImageId); \ No newline at end of file diff --git a/ChatService.Web/Dtos/UploadImageServiceResult.cs b/ChatService.Web/Dtos/UploadImageServiceResult.cs new file mode 100644 index 0000000..686c9e2 --- /dev/null +++ b/ChatService.Web/Dtos/UploadImageServiceResult.cs @@ -0,0 +1,4 @@ +namespace ChatService.Web.Dtos; + +public record UploadImageServiceResult( + string ImageId); \ No newline at end of file diff --git a/ChatService.Web/Dtos/UserConversation.cs b/ChatService.Web/Dtos/UserConversation.cs new file mode 100644 index 0000000..72b379a --- /dev/null +++ b/ChatService.Web/Dtos/UserConversation.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Dtos; + +public record UserConversation +{ + public string Username { get; set; } + public string ConversationId { get; set; } + public long LastModifiedTime { get; set; } +}; \ No newline at end of file diff --git a/ChatService.Web/Enums/OrderBy.cs b/ChatService.Web/Enums/OrderBy.cs new file mode 100644 index 0000000..5ecb8d4 --- /dev/null +++ b/ChatService.Web/Enums/OrderBy.cs @@ -0,0 +1,7 @@ +namespace ChatService.Web.Enums; + +public enum OrderBy +{ + ASC, + DESC +} \ No newline at end of file diff --git a/ChatService.Web/Exceptions/ConversationDoesNotExistException.cs b/ChatService.Web/Exceptions/ConversationDoesNotExistException.cs new file mode 100644 index 0000000..03bd247 --- /dev/null +++ b/ChatService.Web/Exceptions/ConversationDoesNotExistException.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Exceptions; + +public class ConversationDoesNotExistException : Exception +{ + public ConversationDoesNotExistException(string? message) : base(message) + { + } +} \ No newline at end of file diff --git a/ChatService.Web/Exceptions/ConversationExistsException.cs b/ChatService.Web/Exceptions/ConversationExistsException.cs new file mode 100644 index 0000000..84e4e6e --- /dev/null +++ b/ChatService.Web/Exceptions/ConversationExistsException.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Exceptions; + +public class ConversationExistsException : Exception +{ + public ConversationExistsException(string? message) : base(message) + { + } +} \ No newline at end of file diff --git a/ChatService.Web/Exceptions/ImageNotFoundException.cs b/ChatService.Web/Exceptions/ImageNotFoundException.cs new file mode 100644 index 0000000..7103896 --- /dev/null +++ b/ChatService.Web/Exceptions/ImageNotFoundException.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Exceptions; + +public class ImageNotFoundException : Exception +{ + public ImageNotFoundException(string? message) : base(message) + { + } +} \ No newline at end of file diff --git a/ChatService.Web/Exceptions/InvalidContinuationTokenException.cs b/ChatService.Web/Exceptions/InvalidContinuationTokenException.cs new file mode 100644 index 0000000..89a67dd --- /dev/null +++ b/ChatService.Web/Exceptions/InvalidContinuationTokenException.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Exceptions; + +public class InvalidContinuationTokenException : Exception +{ + public InvalidContinuationTokenException(string? message) : base(message) + { + } +} \ No newline at end of file diff --git a/ChatService.Web/Exceptions/InvalidImageTypeException.cs b/ChatService.Web/Exceptions/InvalidImageTypeException.cs new file mode 100644 index 0000000..4801e82 --- /dev/null +++ b/ChatService.Web/Exceptions/InvalidImageTypeException.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Exceptions; + +public class InvalidImageTypeException : Exception +{ + public InvalidImageTypeException(string? message) : base(message) + { + } +} \ No newline at end of file diff --git a/ChatService.Web/Exceptions/InvalidUsernameException.cs b/ChatService.Web/Exceptions/InvalidUsernameException.cs new file mode 100644 index 0000000..c6a8c3a --- /dev/null +++ b/ChatService.Web/Exceptions/InvalidUsernameException.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Exceptions; + +public class InvalidUsernameException : Exception +{ + public InvalidUsernameException(string? message) : base(message) + { + } +} \ No newline at end of file diff --git a/ChatService.Web/Exceptions/MessageExistsException.cs b/ChatService.Web/Exceptions/MessageExistsException.cs new file mode 100644 index 0000000..466d8e5 --- /dev/null +++ b/ChatService.Web/Exceptions/MessageExistsException.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Exceptions; + +public class MessageExistsException : Exception +{ + public MessageExistsException(string? message) : base(message) + { + } +} \ No newline at end of file diff --git a/ChatService.Web/Exceptions/MessageNotFoundException.cs b/ChatService.Web/Exceptions/MessageNotFoundException.cs new file mode 100644 index 0000000..d166121 --- /dev/null +++ b/ChatService.Web/Exceptions/MessageNotFoundException.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Exceptions; + +public class MessageNotFoundException : Exception +{ + public MessageNotFoundException(string? message) : base(message) + { + } +} \ No newline at end of file diff --git a/ChatService.Web/Exceptions/ProfileExistsException.cs b/ChatService.Web/Exceptions/ProfileExistsException.cs new file mode 100644 index 0000000..0763105 --- /dev/null +++ b/ChatService.Web/Exceptions/ProfileExistsException.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Exceptions; + +public class UsernameTakenException : Exception +{ + public UsernameTakenException(string? message) : base(message) + { + } +} \ No newline at end of file diff --git a/ChatService.Web/Exceptions/ProfileNotFoundException.cs b/ChatService.Web/Exceptions/ProfileNotFoundException.cs new file mode 100644 index 0000000..aa8eb9d --- /dev/null +++ b/ChatService.Web/Exceptions/ProfileNotFoundException.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Exceptions; + +public class ProfileNotFoundException : Exception +{ + public ProfileNotFoundException(string? message) : base(message) + { + } +} \ No newline at end of file diff --git a/ChatService.Web/Exceptions/UserConversationExistsException.cs b/ChatService.Web/Exceptions/UserConversationExistsException.cs new file mode 100644 index 0000000..eeef0e7 --- /dev/null +++ b/ChatService.Web/Exceptions/UserConversationExistsException.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Exceptions; + +public class UserConversationExistsException : Exception +{ + public UserConversationExistsException(string? message) : base(message) + { + } +} \ No newline at end of file diff --git a/ChatService.Web/Exceptions/UserConversationNotFoundException.cs b/ChatService.Web/Exceptions/UserConversationNotFoundException.cs new file mode 100644 index 0000000..4f3b566 --- /dev/null +++ b/ChatService.Web/Exceptions/UserConversationNotFoundException.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Exceptions; + +public class UserConversationNotFoundException : Exception +{ + public UserConversationNotFoundException(string? message) : base(message) + { + } +} \ No newline at end of file diff --git a/ChatService.Web/Exceptions/UserNotFoundException.cs b/ChatService.Web/Exceptions/UserNotFoundException.cs new file mode 100644 index 0000000..77e8e75 --- /dev/null +++ b/ChatService.Web/Exceptions/UserNotFoundException.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Exceptions; + +public class UserNotFoundException : Exception +{ + public UserNotFoundException(string? message) : base(message) + { + } +} \ No newline at end of file diff --git a/ChatService.Web/Exceptions/UserNotParticipantException.cs b/ChatService.Web/Exceptions/UserNotParticipantException.cs new file mode 100644 index 0000000..e76412f --- /dev/null +++ b/ChatService.Web/Exceptions/UserNotParticipantException.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Exceptions; + +public class UserNotParticipantException : Exception +{ + public UserNotParticipantException(string? message) : base(message) + { + } +} \ No newline at end of file diff --git a/ChatService.Web/Program.cs b/ChatService.Web/Program.cs new file mode 100644 index 0000000..1aa2e85 --- /dev/null +++ b/ChatService.Web/Program.cs @@ -0,0 +1,56 @@ +using Azure.Storage.Blobs; +using ChatService.Web.Configuration; +using ChatService.Web.Services; +using ChatService.Web.Storage; +using Microsoft.Azure.Cosmos; +using Microsoft.Extensions.Options; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.Configure(builder.Configuration.GetSection("Cosmos")); +builder.Services.Configure(builder.Configuration.GetSection("BlobStorage")); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +builder.Services.AddSingleton(sp => +{ + var cosmosOptions = sp.GetRequiredService>(); + return new CosmosClient(cosmosOptions.Value.ConnectionString); +}); +builder.Services.AddSingleton(sp => + { + var blobOptions = sp.GetRequiredService>(); + return new BlobServiceClient(blobOptions.Value.ConnectionString); + } +); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddApplicationInsightsTelemetry(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); + +public partial class Program { } \ No newline at end of file diff --git a/ChatService.Web/Properties/launchSettings.json b/ChatService.Web/Properties/launchSettings.json new file mode 100644 index 0000000..9f50bb5 --- /dev/null +++ b/ChatService.Web/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:61550", + "sslPort": 44359 + } + }, + "profiles": { + "ChatService.Web": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7185;http://localhost:5157", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/ChatService.Web/Services/IImageService.cs b/ChatService.Web/Services/IImageService.cs new file mode 100644 index 0000000..36ca1a7 --- /dev/null +++ b/ChatService.Web/Services/IImageService.cs @@ -0,0 +1,12 @@ +using ChatService.Web.Dtos; +using Microsoft.AspNetCore.Mvc; + +namespace ChatService.Web.Services; + +public interface IImageService +{ + Task UploadImage(Image image); + Task DownloadImage(string imageId); + Task DeleteImage(string imageId); + Task ImageExists(string imageId); +} \ No newline at end of file diff --git a/ChatService.Web/Services/IMessageService.cs b/ChatService.Web/Services/IMessageService.cs new file mode 100644 index 0000000..70ca87a --- /dev/null +++ b/ChatService.Web/Services/IMessageService.cs @@ -0,0 +1,12 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Enums; + +namespace ChatService.Web.Services; + +public interface IMessageService +{ + Task AddMessage(string conversationId, bool isFirstMessage, SendMessageRequest request); + Task AddFirstMessage(string conversationId, SendMessageRequest request); + Task GetMessages( + string conversationId, int limit, OrderBy orderBy, string? continuationToken, long lastSeenConversationTime); +} \ No newline at end of file diff --git a/ChatService.Web/Services/IProfileService.cs b/ChatService.Web/Services/IProfileService.cs new file mode 100644 index 0000000..28c493d --- /dev/null +++ b/ChatService.Web/Services/IProfileService.cs @@ -0,0 +1,11 @@ +using ChatService.Web.Dtos; + +namespace ChatService.Web.Services; + +public interface IProfileService +{ + Task GetProfile(string username); + Task AddProfile(Profile profile); + Task ProfileExists(string username); + Task DeleteProfile(string username); +} \ No newline at end of file diff --git a/ChatService.Web/Services/IUserConversationService.cs b/ChatService.Web/Services/IUserConversationService.cs new file mode 100644 index 0000000..caff149 --- /dev/null +++ b/ChatService.Web/Services/IUserConversationService.cs @@ -0,0 +1,11 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Enums; + +namespace ChatService.Web.Services; + +public interface IUserConversationService +{ + Task CreateConversation(StartConversationRequest request); + Task GetUserConversations( + string username, int limit, OrderBy orderBy, string? continuationToken, long lastSeenConversationTime); +} \ No newline at end of file diff --git a/ChatService.Web/Services/ImageService.cs b/ChatService.Web/Services/ImageService.cs new file mode 100644 index 0000000..9b0a053 --- /dev/null +++ b/ChatService.Web/Services/ImageService.cs @@ -0,0 +1,69 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Exceptions; +using ChatService.Web.Storage; +using Microsoft.AspNetCore.Mvc; + +namespace ChatService.Web.Services; + +public class ImageService : IImageService +{ + private readonly IImageStore _imageStore; + + public ImageService(IImageStore imageStore) + { + _imageStore = imageStore; + } + + public async Task UploadImage(Image image) + { + ValidateImage(image); + + string imageId = await _imageStore.UploadImage(image); + + return new UploadImageServiceResult(imageId); + } + + public async Task DownloadImage(string imageId) + { + ValidateImageId(imageId); + + Image? image = await _imageStore.DownloadImage(imageId); + + if (image == null) + { + throw new ImageNotFoundException($"An image with id {imageId} was not found."); + } + + return new FileContentResult(image.Content.ToArray(), image.ContentType); + } + + public async Task DeleteImage(string imageId) + { + await _imageStore.DeleteImage(imageId); + } + + public async Task ImageExists(string imageId) + { + return await _imageStore.ImageExists(imageId); + } + + private void ValidateImage(Image image) + { + string contentType = image.ContentType.ToLower(); + + if (contentType != "image/jpg" && + contentType != "image/jpeg" && + contentType != "image/png") + { + throw new InvalidImageTypeException($"Invalid image type {contentType}."); + } + } + + private void ValidateImageId(string imageId) + { + if (string.IsNullOrWhiteSpace(imageId)) + { + throw new ArgumentException("Invalid imageId"); + } + } +} \ No newline at end of file diff --git a/ChatService.Web/Services/MessageService.cs b/ChatService.Web/Services/MessageService.cs new file mode 100644 index 0000000..b39861f --- /dev/null +++ b/ChatService.Web/Services/MessageService.cs @@ -0,0 +1,131 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Enums; +using ChatService.Web.Exceptions; +using ChatService.Web.Storage; + +namespace ChatService.Web.Services; + +public class MessageService : IMessageService +{ + private readonly IMessageStore _messageStore; + private readonly IProfileService _profileService; + + public MessageService(IMessageStore messageStore, IProfileService profileService) + { + _messageStore = messageStore; + _profileService = profileService; + } + + public async Task AddMessage(string conversationId, bool isFirstMessage, + SendMessageRequest request) + { + ValidateSendMessageRequest(request); + ValidateConversationId(conversationId); + + if (!isFirstMessage && !await _messageStore.ConversationPartitionExists(conversationId)) + { + throw new ConversationDoesNotExistException( + $"A conversation partition with the conversationId {conversationId} does not exist."); + } + + if (!await _profileService.ProfileExists(request.SenderUsername)) + { + throw new ProfileNotFoundException( + $"A profile with the username {request.SenderUsername} was not found."); + } + + AuthorizeSender(conversationId, request.SenderUsername); + + long unixTimeNow = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + Message message = new Message + { + MessageId = request.MessageId, + UnixTime = unixTimeNow, + SenderUsername = request.SenderUsername, + Text = request.Text + }; + + await _messageStore.AddMessage(conversationId, message); + + return new SendMessageResponse + { + CreatedUnixTime = unixTimeNow + }; + } + + public async Task AddFirstMessage(string conversationId, SendMessageRequest request) + { + return await AddMessage(conversationId, true, request); + } + + public async Task GetMessages(string conversationId, int limit, OrderBy orderBy, + string? continuationToken, long lastSeenConversationTime) + { + ValidateConversationId(conversationId); + ValidateLimit(limit); + ValidateLastSeenConversationTime(lastSeenConversationTime); + + if (!await _messageStore.ConversationPartitionExists(conversationId)) + { + throw new ConversationDoesNotExistException( + $"A conversation partition with the conversationId {conversationId} does not exist."); + } + + var result = await _messageStore.GetMessages( + conversationId, limit, orderBy, continuationToken, lastSeenConversationTime); + + return new GetMessagesServiceResult + { + Messages = result.Messages, + NextContinuationToken = result.NextContinuationToken + }; + } + + private void ValidateSendMessageRequest(SendMessageRequest request) + { + if (request == null || + string.IsNullOrWhiteSpace(request.MessageId) || + string.IsNullOrWhiteSpace(request.SenderUsername) || + string.IsNullOrWhiteSpace(request.Text) + ) + { + throw new ArgumentException($"Invalid SendMessageRequest {request}."); + } + } + + private void ValidateConversationId(string conversationId) + { + if (string.IsNullOrWhiteSpace(conversationId) || !conversationId.Contains('_')) + { + throw new ArgumentException($"Invalid conversationId {conversationId}."); + } + } + + private void ValidateLimit(int limit) + { + if (limit <= 0) + { + throw new ArgumentException($"Invalid limit {limit}. Limit must be greater or equal to 1."); + } + } + + private void ValidateLastSeenConversationTime(long lastSeenConversationTime) + { + if (lastSeenConversationTime < 0) + { + throw new ArgumentException($"Invalid lastSeenConversationTime {lastSeenConversationTime}. " + + $"LastSeenConversationTime must be greater or equal to 0."); + } + } + + private void AuthorizeSender(string conversationId, string senderUsername) + { + string[] usernames = conversationId.Split('_'); + if (!usernames[0].Equals(senderUsername) && !usernames[1].Equals(senderUsername)) + { + throw new UserNotParticipantException( + $"User {senderUsername} is not a participant of conversation {conversationId}."); + } + } +} \ No newline at end of file diff --git a/ChatService.Web/Services/ProfileService.cs b/ChatService.Web/Services/ProfileService.cs new file mode 100644 index 0000000..5b4638f --- /dev/null +++ b/ChatService.Web/Services/ProfileService.cs @@ -0,0 +1,91 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Exceptions; +using ChatService.Web.Storage; + +namespace ChatService.Web.Services; + +public class ProfileService : IProfileService +{ + private readonly IProfileStore _profileStore; + private readonly IImageService _imageService; + + public ProfileService(IProfileStore profileStore, IImageService imageService) + { + _profileStore = profileStore; + _imageService = imageService; + } + + public async Task GetProfile(string username) + { + ValidateUsername(username); + + var profile = await _profileStore.GetProfile(username); + + if (profile == null) + { + throw new ProfileNotFoundException( + $"A profile with the username {username} was not found."); + } + + return profile; + } + + public async Task AddProfile(Profile profile) + { + ValidateProfile(profile); + + bool imageExists = await _imageService.ImageExists(profile.ProfilePictureId); + if (!imageExists) + { + throw new ImageNotFoundException($"Profile picture with ID {profile.ProfilePictureId} was not found."); + } + + await _profileStore.AddProfile(profile); + } + + public async Task ProfileExists(string username) + { + if (string.IsNullOrWhiteSpace(username)) + { + throw new ArgumentException($"Invalid username {username}."); + + } + return await _profileStore.ProfileExists(username); + } + + public async Task DeleteProfile(string username) + { + Profile? profile = await GetProfile(username); + if (profile == null) + { + throw new ProfileNotFoundException($"Profile with username {username} does not exist."); + } + await _imageService.DeleteImage(profile.ProfilePictureId); + await _profileStore.DeleteProfile(username); + } + + private void ValidateUsername(string username) + { + if (string.IsNullOrWhiteSpace(username)) + { + throw new ArgumentException($"Invalid username {username}"); + } + } + + private void ValidateProfile(Profile profile) + { + if (profile == null || + string.IsNullOrWhiteSpace(profile.Username) || + string.IsNullOrWhiteSpace(profile.FirstName) || + string.IsNullOrWhiteSpace(profile.LastName) || + string.IsNullOrWhiteSpace(profile.ProfilePictureId) + ) + { + throw new ArgumentException($"Invalid profile {profile}", nameof(profile)); + } + if (profile.Username.Contains('_')) + { + throw new InvalidUsernameException($"Username {profile.Username} is invalid. Usernames cannot have an underscore."); + } + } +} \ No newline at end of file diff --git a/ChatService.Web/Services/UserConversationService.cs b/ChatService.Web/Services/UserConversationService.cs new file mode 100644 index 0000000..8d773a5 --- /dev/null +++ b/ChatService.Web/Services/UserConversationService.cs @@ -0,0 +1,168 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Enums; +using ChatService.Web.Exceptions; +using ChatService.Web.Storage; +using ChatService.Web.Utilities; + +namespace ChatService.Web.Services; + +public class UserConversationService : IUserConversationService +{ + + private readonly IMessageService _messageService; + private readonly IUserConversationStore _userConversationStore; + private readonly IProfileService _profileService; + + public UserConversationService(IMessageService messageService, IUserConversationStore userConversationStore, + IProfileService profileService) + { + _messageService = messageService; + _userConversationStore = userConversationStore; + _profileService = profileService; + } + + public async Task CreateConversation(StartConversationRequest request) + { + ValidateStartConversationRequest(request); + + string username1 = request.Participants.ElementAt(0); + string username2 = request.Participants.ElementAt(1); + + if (!await _profileService.ProfileExists(username1)) + { + throw new ProfileNotFoundException($"A profile with the username {username1} was not found."); + } + + if (!await _profileService.ProfileExists(username2)) + { + throw new ProfileNotFoundException($"A profile with the username {username2} was not found."); + } + + string conversationId = ConversationIdUtilities.GenerateConversationId(username1, username2); + + long unixTimeNow = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + SendMessageRequest sendMessageRequest = new SendMessageRequest + { + MessageId = request.FirstMessage.MessageId, + SenderUsername = request.FirstMessage.SenderUsername, + Text = request.FirstMessage.Text + }; + await _messageService.AddFirstMessage(conversationId, sendMessageRequest); + + UserConversation userConversation1 = new UserConversation + { + Username = username1, + ConversationId = conversationId, + LastModifiedTime = unixTimeNow + }; + await _userConversationStore.CreateUserConversation(userConversation1); + + UserConversation userConversation2 = new UserConversation + { + Username = username2, + ConversationId = conversationId, + LastModifiedTime = unixTimeNow + }; + await _userConversationStore.CreateUserConversation(userConversation2); + + return new StartConversationServiceResult + { + ConversationId = conversationId, + CreatedUnixTime = unixTimeNow + }; + } + + public async Task GetUserConversations( + string username, int limit, OrderBy orderBy, string? continuationToken, long lastSeenConversationTime) + { + if (string.IsNullOrWhiteSpace(username)) + { + throw new ArgumentException($"Invalid username {username}."); + } + + if (limit <= 0) + { + throw new ArgumentException($"Invalid limit {limit}. Limit must be greater or equal to 1."); + } + + if (lastSeenConversationTime < 0) + { + throw new ArgumentException( + $"Invalid lastSeenConversationTime {lastSeenConversationTime}. lastSeenConversationTime must be greater or equal to 0."); + } + + if (!await _profileService.ProfileExists(username)) + { + throw new UserNotFoundException($"User {username} was not found."); + } + + var result = await _userConversationStore.GetUserConversations( + username, limit, orderBy, continuationToken, lastSeenConversationTime); + + List conversations = await UserConversationsToConversations(result.UserConversations); + + return new GetUserConversationsServiceResult + { + Conversations = conversations, + NextContinuationToken = result.NextContinuationToken + }; + } + + private async Task> UserConversationsToConversations(List userConversations) + { + List conversations = new(); + + foreach (UserConversation userConversation in userConversations) + { + string[] usernames = userConversation.ConversationId.Split('_'); + string recipientUsername; + + if (usernames[0].Equals(userConversation.Username)) + { + recipientUsername = usernames[1]; + } + else + { + recipientUsername = usernames[0]; + } + + Profile recipientProfile = await _profileService.GetProfile(recipientUsername); + + Conversation conversation = new Conversation + { + ConversationId = userConversation.ConversationId, + LastModifiedUnixTime = userConversation.LastModifiedTime, + Recipient = recipientProfile + }; + + conversations.Add(conversation); + } + + return conversations; + } + + private void ValidateStartConversationRequest(StartConversationRequest request) + { + if (request == null) + { + throw new ArgumentException($"StartConversationRequest is null."); + } + + if (request.Participants.Count < 2 || + string.IsNullOrWhiteSpace(request.Participants.ElementAt(0)) || + string.IsNullOrWhiteSpace(request.Participants.ElementAt(1)) || + request.Participants.ElementAt(0).Equals(request.Participants.ElementAt(1))) + { + throw new ArgumentException( + $"Invalid participants list ${request.Participants}. There must be 2 unique participant usernames"); + } + + if (string.IsNullOrWhiteSpace(request.FirstMessage.MessageId) || + string.IsNullOrWhiteSpace(request.FirstMessage.SenderUsername) || + string.IsNullOrWhiteSpace(request.FirstMessage.Text)) + { + throw new ArgumentException($"Invalid FirstMessage {request.FirstMessage}."); + } + } +} \ No newline at end of file diff --git a/ChatService.Web/Storage/BlobImageStore.cs b/ChatService.Web/Storage/BlobImageStore.cs new file mode 100644 index 0000000..ad60532 --- /dev/null +++ b/ChatService.Web/Storage/BlobImageStore.cs @@ -0,0 +1,78 @@ +using Azure; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using ChatService.Web.Dtos; + +namespace ChatService.Web.Storage; + +public class BlobImageStore : IImageStore +{ + private readonly BlobServiceClient _blobServiceClient; + + public BlobImageStore(BlobServiceClient blobServiceClient) + { + _blobServiceClient = blobServiceClient; + } + + private BlobContainerClient BlobContainerClient => _blobServiceClient.GetBlobContainerClient("images"); + + public async Task UploadImage(Image image) + { + ValidateImage(image); + + string imageId = Guid.NewGuid().ToString(); + BlobClient blobClient = BlobContainerClient.GetBlobClient(imageId); + BlobHttpHeaders headers = new BlobHttpHeaders + { + ContentType = image.ContentType + }; + image.Content.Position = 0; + await blobClient.UploadAsync(image.Content, headers); + return imageId; + } + + public async Task DownloadImage(string id) + { + BlobClient blobClient = BlobContainerClient.GetBlobClient(id); + + try + { + MemoryStream content = new MemoryStream(); + await blobClient.DownloadToAsync(content); + BlobProperties properties = await blobClient.GetPropertiesAsync(); + string contentType = properties.ContentType; + return new Image(contentType, content); + } + catch (RequestFailedException ex) + { + if (ex.Status == 404) + { + return null; + } + throw; + } + } + + public async Task DeleteImage(string id) + { + BlobClient blobClient = BlobContainerClient.GetBlobClient(id); + return await blobClient.DeleteIfExistsAsync(); + } + + public async Task ImageExists(string id) + { + BlobClient blobClient = BlobContainerClient.GetBlobClient(id); + return await blobClient.ExistsAsync(); + } + + private void ValidateImage(Image image) + { + string contentType = image.ContentType.ToLower(); + if (contentType != "image/jpg" && + contentType != "image/jpeg" && + contentType != "image/png") + { + throw new ArgumentException("File type is not an image."); + } + } +} \ No newline at end of file diff --git a/ChatService.Web/Storage/CosmosMessageStore.cs b/ChatService.Web/Storage/CosmosMessageStore.cs new file mode 100644 index 0000000..4490c2a --- /dev/null +++ b/ChatService.Web/Storage/CosmosMessageStore.cs @@ -0,0 +1,210 @@ +using System.Net; +using ChatService.Web.Dtos; +using ChatService.Web.Enums; +using ChatService.Web.Exceptions; +using ChatService.Web.Storage.Entities; +using Microsoft.Azure.Cosmos; +using Microsoft.Azure.Cosmos.Linq; + +namespace ChatService.Web.Storage; + +public class CosmosMessageStore : IMessageStore +{ + private readonly CosmosClient _cosmosClient; + + public CosmosMessageStore(CosmosClient cosmosClient) + { + _cosmosClient = cosmosClient; + } + + private Container Container => _cosmosClient.GetDatabase("chatService").GetContainer("sharedContainer"); + + public async Task AddMessage(string conversationId, Message message) + { + ValidateMessage(message); + + try + { + await Container.CreateItemAsync(ToEntity(conversationId, message), new PartitionKey(conversationId)); + } + catch (CosmosException e) + { + if (e.StatusCode == HttpStatusCode.Conflict) + { + throw new MessageExistsException($"A message with ID {message.MessageId} already exists."); + } + throw; + } + } + + public async Task GetMessage(string conversationId, string messageId) + { + ValidateConversationId(conversationId); + ValidateMessageId(messageId); + + try + { + var entity = await Container.ReadItemAsync( + id: messageId, + partitionKey: new PartitionKey(conversationId), + new ItemRequestOptions + { + ConsistencyLevel = ConsistencyLevel.Session + } + ); + return ToMessage(entity); + } + catch (CosmosException e) + { + if (e.StatusCode == HttpStatusCode.NotFound) + { + throw new MessageNotFoundException($"A message with messageId {messageId} was not found."); + } + throw; + } + } + + public async Task<(List Messages, string NextContinuationToken)> GetMessages( + string conversationId, int limit, OrderBy order, string? continuationToken, long lastSeenMessageTime) + { + ValidateConversationId(conversationId); + ValidateLimit(limit); + ValidateLastSeenMessageTime(lastSeenMessageTime); + + List messages = new (); + string? nextContinuationToken = null; + + QueryRequestOptions options = new QueryRequestOptions(); + options.MaxItemCount = limit; + + try + { + IQueryable query = Container + .GetItemLinqQueryable(false, continuationToken, options) + .Where(e => e.partitionKey == conversationId && e.UnixTime > lastSeenMessageTime); + + if (order == OrderBy.ASC) + { + query = query.OrderBy(e => e.UnixTime); + } + else + { + query = query.OrderByDescending(e => e.UnixTime); + } + + using (FeedIterator iterator = query.ToFeedIterator()) + { + FeedResponse response = await iterator.ReadNextAsync(); + var receivedUserConversations = response.Select(ToMessage); + + messages.AddRange(receivedUserConversations); + + nextContinuationToken = response.ContinuationToken; + }; + + return (messages, nextContinuationToken); + } + catch (CosmosException e) + { + if (e.StatusCode == HttpStatusCode.BadRequest) + { + throw new InvalidContinuationTokenException($"Continuation token {continuationToken} is invalid."); + } + throw; + } + } + + public async Task ConversationPartitionExists(string conversationId) + { + var response = await GetMessages( + conversationId, 1, OrderBy.ASC, null, 0); + + return (response.Messages.Count > 0); + } + + public async Task DeleteMessage(string conversationId, string messageId) + { + try + { + await Container.DeleteItemAsync( + id: messageId, + partitionKey: new PartitionKey(conversationId)); + } + catch (CosmosException e) + { + if (e.StatusCode == HttpStatusCode.NotFound) + { + return; + } + throw; + } + } + + private static MessageEntity ToEntity(string conversationId, Message message) + { + return new MessageEntity( + partitionKey: conversationId, + id: message.MessageId, + message.UnixTime, + message.SenderUsername, + message.Text + ); + } + + private static Message ToMessage(MessageEntity entity) + { + return new Message + { + MessageId = entity.id, + UnixTime = entity.UnixTime, + SenderUsername = entity.SenderUsername, + Text = entity.Text + }; + } + + private void ValidateMessage(Message message) + { + if (message == null || + string.IsNullOrWhiteSpace(message.MessageId) || + string.IsNullOrWhiteSpace(message.SenderUsername) || + string.IsNullOrWhiteSpace(message.Text) || + message.UnixTime < 0 + ) + { + throw new ArgumentException($"Invalid message {message}", nameof(message)); + } + } + + private void ValidateConversationId(string conversationId) + { + if (string.IsNullOrWhiteSpace(conversationId) || !conversationId.Contains('_')) + { + throw new ArgumentException($"Invalid conversationId {conversationId}."); + } + } + + private void ValidateMessageId(string messageId) + { + if (string.IsNullOrWhiteSpace(messageId)) + { + throw new ArgumentException($"Invalid messageId {messageId}"); + } + } + + private void ValidateLimit(int limit) + { + if (limit <= 0) + { + throw new ArgumentException($"Invalid limit {limit}. Limit must be greater or equal to 1."); + } + } + + private void ValidateLastSeenMessageTime(long lastSeenMessageTime) + { + if (lastSeenMessageTime < 0) + { + throw new ArgumentException( + $"Invalid lastSeenMessageTime {lastSeenMessageTime}. LastSeenMessageTime must be greater or equal to 0."); + } + } +} \ No newline at end of file diff --git a/ChatService.Web/Storage/CosmosProfileStore.cs b/ChatService.Web/Storage/CosmosProfileStore.cs new file mode 100644 index 0000000..80dc9f8 --- /dev/null +++ b/ChatService.Web/Storage/CosmosProfileStore.cs @@ -0,0 +1,125 @@ +using System.Net; +using ChatService.Web.Dtos; +using ChatService.Web.Exceptions; +using ChatService.Web.Storage.Entities; +using Microsoft.Azure.Cosmos; + +namespace ChatService.Web.Storage; + +public class CosmosProfileStore : IProfileStore +{ + private readonly CosmosClient _cosmosClient; + + public CosmosProfileStore(CosmosClient cosmosClient) + { + _cosmosClient = cosmosClient; + } + + private Container Container => _cosmosClient.GetDatabase("chatService").GetContainer("sharedContainer"); + + public async Task AddProfile(Profile profile) + { + ValidateProfile(profile); + + try + { + await Container.CreateItemAsync(ToEntity(profile), new PartitionKey(profile.Username)); + } + catch (CosmosException e) + { + if (e.StatusCode == HttpStatusCode.Conflict) + { + throw new UsernameTakenException($"A profile with username {profile.Username} already exists."); + } + throw; + } + } + + public async Task GetProfile(string username) + { + try + { + var entity = await Container.ReadItemAsync( + id: username, + partitionKey: new PartitionKey(username), + new ItemRequestOptions + { + ConsistencyLevel = ConsistencyLevel.Session + } + ); + return ToProfile(entity); + } + catch (CosmosException e) + { + if (e.StatusCode == HttpStatusCode.NotFound) + { + return null; + } + throw; + } + } + + public async Task DeleteProfile(string username) + { + try + { + await Container.DeleteItemAsync( + id: username, + partitionKey: new PartitionKey(username)); + } + catch (CosmosException e) + { + if (e.StatusCode == HttpStatusCode.NotFound) + { + return; + } + throw; + } + } + + public async Task ProfileExists(string username) + { + Profile profile = await GetProfile(username); + + return profile != null; + } + + private static ProfileEntity ToEntity(Profile profile) + { + return new ProfileEntity( + partitionKey: profile.Username, + id: profile.Username, + profile.FirstName, + profile.LastName, + profile.ProfilePictureId + ); + } + + private static Profile ToProfile(ProfileEntity entity) + { + return new Profile + { + Username = entity.id, + FirstName = entity.FirstName, + LastName = entity.LastName, + ProfilePictureId = entity.ProfilePictureId + }; + } + + private void ValidateProfile(Profile profile) + { + if (profile == null || + string.IsNullOrWhiteSpace(profile.Username) || + string.IsNullOrWhiteSpace(profile.FirstName) || + string.IsNullOrWhiteSpace(profile.LastName) || + string.IsNullOrWhiteSpace(profile.ProfilePictureId) + ) + { + throw new ArgumentException($"Invalid profile {profile}", nameof(profile)); + } + if (profile.Username.Contains('_')) + { + throw new InvalidUsernameException($"Username {profile.Username} is invalid. Usernames cannot have an underscore."); + } + } +} \ No newline at end of file diff --git a/ChatService.Web/Storage/CosmosUserConversationStore.cs b/ChatService.Web/Storage/CosmosUserConversationStore.cs new file mode 100644 index 0000000..4b7c8a1 --- /dev/null +++ b/ChatService.Web/Storage/CosmosUserConversationStore.cs @@ -0,0 +1,197 @@ +using System.Net; +using ChatService.Web.Dtos; +using ChatService.Web.Enums; +using ChatService.Web.Exceptions; +using ChatService.Web.Storage.Entities; +using Microsoft.Azure.Cosmos; +using Microsoft.Azure.Cosmos.Linq; + +namespace ChatService.Web.Storage; + +public class CosmosUserConversationStore : IUserConversationStore +{ + private readonly CosmosClient _cosmosClient; + + public CosmosUserConversationStore(CosmosClient cosmosClient) + { + _cosmosClient = cosmosClient; + } + + private Container Container => _cosmosClient.GetDatabase("chatService").GetContainer("sharedContainer"); + + public async Task CreateUserConversation(UserConversation userConversation) + { + ValidateUserConversation(userConversation); + + try + { + await Container.CreateItemAsync(ToEntity(userConversation), new PartitionKey(userConversation.Username)); + } + catch (CosmosException e) + { + if (e.StatusCode == HttpStatusCode.Conflict) + { + throw new UserConversationExistsException($"A user conversation with conversation ID {userConversation.ConversationId} already exists."); + } + throw; + } + } + + public async Task GetUserConversation(string username, string conversationId) + { + ValidateUsername(username); + ValidateConversationId(conversationId); + + try + { + var entity = await Container.ReadItemAsync( + id: conversationId, + partitionKey: new PartitionKey(username), + new ItemRequestOptions + { + ConsistencyLevel = ConsistencyLevel.Session + } + ); + return ToUserConversation(entity); + } + catch (CosmosException e) + { + if (e.StatusCode == HttpStatusCode.NotFound) + { + throw new UserConversationNotFoundException($"A UserConversation with conversationId {conversationId} was not found."); + } + throw; + } + } + + public async Task<(List UserConversations, string NextContinuationToken)> GetUserConversations + (string username, int limit, OrderBy order, string? continuationToken, long lastSeenConversationTime) + { + ValidateUsername(username); + ValidateLimit(limit); + ValidateLastSeenMessageTime(lastSeenConversationTime); + + List userConversations = new (); + string? nextContinuationToken = null; + + QueryRequestOptions options = new QueryRequestOptions(); + options.MaxItemCount = limit; + + try + { + IQueryable query = Container + .GetItemLinqQueryable(false, continuationToken, options) + .Where(e => e.partitionKey == username && e.LastModifiedTime > lastSeenConversationTime); + + if (order == OrderBy.ASC) + { + query = query.OrderBy(e => e.LastModifiedTime); + } + else + { + query = query.OrderByDescending(e => e.LastModifiedTime); + } + + using (FeedIterator iterator = query.ToFeedIterator()) + { + FeedResponse response = await iterator.ReadNextAsync(); + var receivedUserConversations = response.Select(ToUserConversation); + + userConversations.AddRange(receivedUserConversations); + + nextContinuationToken = response.ContinuationToken; + }; + + return (userConversations, nextContinuationToken); + } + catch (CosmosException e) + { + if (e.StatusCode == HttpStatusCode.BadRequest) + { + throw new InvalidContinuationTokenException($"Continuation token {continuationToken} is invalid."); + } + throw; + } + } + + public async Task DeleteUserConversation(string username, string conversationId) + { + try + { + await Container.DeleteItemAsync( + id: conversationId, + partitionKey: new PartitionKey(username)); + } + catch (CosmosException e) + { + if (e.StatusCode == HttpStatusCode.NotFound) + { + return; + } + throw; + } + } + + private static UserConversationEntity ToEntity(UserConversation userConversation) + { + return new UserConversationEntity( + partitionKey: userConversation.Username, + id: userConversation.ConversationId, + userConversation.LastModifiedTime + ); + } + + private static UserConversation ToUserConversation(UserConversationEntity entity) + { + return new UserConversation { + Username = entity.partitionKey, + ConversationId = entity.id, + LastModifiedTime = entity.LastModifiedTime + }; + } + + private void ValidateUserConversation(UserConversation userConversation) + { + if (userConversation == null || + string.IsNullOrWhiteSpace(userConversation.Username) || + string.IsNullOrWhiteSpace(userConversation.ConversationId) || + userConversation.LastModifiedTime < 0 + ) + { + throw new ArgumentException($"Invalid user conversation {userConversation}", nameof(userConversation)); + } + } + + private void ValidateUsername(string username) + { + if (string.IsNullOrWhiteSpace(username)) + { + throw new ArgumentException($"Invalid username {username}"); + } + } + + private void ValidateConversationId(string conversationId) + { + if (string.IsNullOrWhiteSpace(conversationId) || !conversationId.Contains('_')) + { + throw new ArgumentException($"Invalid conversationId {conversationId}."); + } + } + + private void ValidateLimit(int limit) + { + if (limit <= 0) + { + throw new ArgumentException($"Invalid limit {limit}. Limit must be greater or equal to 1."); + } + } + + private void ValidateLastSeenMessageTime(long lastSeenConversationTime) + { + if (lastSeenConversationTime < 0) + { + throw new ArgumentException( + $"Invalid lastSeenConversationTime {lastSeenConversationTime}. LastSeenConversationTime must be greater or equal to 0."); + } + } +} \ No newline at end of file diff --git a/ChatService.Web/Storage/Entities/MessageEntity.cs b/ChatService.Web/Storage/Entities/MessageEntity.cs new file mode 100644 index 0000000..f961f81 --- /dev/null +++ b/ChatService.Web/Storage/Entities/MessageEntity.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Storage.Entities; + +public record MessageEntity( + string partitionKey, + string id, + long UnixTime, + string SenderUsername, + string Text); \ No newline at end of file diff --git a/ChatService.Web/Storage/Entities/ProfileEntity.cs b/ChatService.Web/Storage/Entities/ProfileEntity.cs new file mode 100644 index 0000000..f7b41f3 --- /dev/null +++ b/ChatService.Web/Storage/Entities/ProfileEntity.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Storage.Entities; + +public record ProfileEntity( + string partitionKey, + string id, + string FirstName, + string LastName, + string ProfilePictureId); diff --git a/ChatService.Web/Storage/Entities/UserConversationEntity.cs b/ChatService.Web/Storage/Entities/UserConversationEntity.cs new file mode 100644 index 0000000..bd39b7d --- /dev/null +++ b/ChatService.Web/Storage/Entities/UserConversationEntity.cs @@ -0,0 +1,6 @@ +namespace ChatService.Web.Storage.Entities; + +public record UserConversationEntity( + string partitionKey, + string id, + long LastModifiedTime); \ No newline at end of file diff --git a/ChatService.Web/Storage/IImageStore.cs b/ChatService.Web/Storage/IImageStore.cs new file mode 100644 index 0000000..2d5b31e --- /dev/null +++ b/ChatService.Web/Storage/IImageStore.cs @@ -0,0 +1,11 @@ +using ChatService.Web.Dtos; + +namespace ChatService.Web.Storage; + +public interface IImageStore +{ + Task UploadImage(Image image); + Task DownloadImage(string id); + Task DeleteImage(string id); + Task ImageExists(string id); +} \ No newline at end of file diff --git a/ChatService.Web/Storage/IMessageStore.cs b/ChatService.Web/Storage/IMessageStore.cs new file mode 100644 index 0000000..109a884 --- /dev/null +++ b/ChatService.Web/Storage/IMessageStore.cs @@ -0,0 +1,14 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Enums; + +namespace ChatService.Web.Storage; + +public interface IMessageStore +{ + Task AddMessage(string conversationId, Message message); + Task GetMessage(string conversationId, string messageId); + Task<(List Messages, string NextContinuationToken)> GetMessages( + string conversationId, int limit, OrderBy order, string? continuationToken, long lastSeenMessageTime); + Task ConversationPartitionExists(string conversationId); + Task DeleteMessage(string conversationId, string messageId); +} diff --git a/ChatService.Web/Storage/IProfileStore.cs b/ChatService.Web/Storage/IProfileStore.cs new file mode 100644 index 0000000..a0104f7 --- /dev/null +++ b/ChatService.Web/Storage/IProfileStore.cs @@ -0,0 +1,11 @@ +using ChatService.Web.Dtos; + +namespace ChatService.Web.Storage; + +public interface IProfileStore +{ + Task AddProfile(Profile profile); + Task GetProfile(string username); + Task DeleteProfile(string username); + Task ProfileExists(string username); +} \ No newline at end of file diff --git a/ChatService.Web/Storage/IUserConversationStore.cs b/ChatService.Web/Storage/IUserConversationStore.cs new file mode 100644 index 0000000..9980580 --- /dev/null +++ b/ChatService.Web/Storage/IUserConversationStore.cs @@ -0,0 +1,13 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Enums; + +namespace ChatService.Web.Storage; + +public interface IUserConversationStore +{ + Task CreateUserConversation(UserConversation userConversation); + Task GetUserConversation(string username, string conversationId); + Task<(List UserConversations, string NextContinuationToken)> GetUserConversations( + string username, int limit, OrderBy order, string? continuationToken, long lastSeenConversationTime); + Task DeleteUserConversation(string username, string conversationId); +} \ No newline at end of file diff --git a/ChatService.Web/Utilities/ConversationIdUtilities.cs b/ChatService.Web/Utilities/ConversationIdUtilities.cs new file mode 100644 index 0000000..8136344 --- /dev/null +++ b/ChatService.Web/Utilities/ConversationIdUtilities.cs @@ -0,0 +1,14 @@ +namespace ChatService.Web.Utilities; + +public class ConversationIdUtilities +{ + public static string GenerateConversationId(string username1, string username2) + { + if (username1.CompareTo(username2) < 0) + { + return username1 + "_" + username2; + } + + return username2 + "_" + username1; + } +} \ No newline at end of file diff --git a/ChatService.Web/appsettings.Development.json b/ChatService.Web/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/ChatService.Web/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/ChatService.Web/appsettings.json b/ChatService.Web/appsettings.json new file mode 100644 index 0000000..d6a643d --- /dev/null +++ b/ChatService.Web/appsettings.json @@ -0,0 +1,23 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Trace", + "Microsoft.AspNetCore": "Warning" + }, + "ApplicationInsights": { + "LogLevel": { + "Default": "Trace" + } + } + }, + + "Cosmos": { + "ConnectionString": "" + }, + + "BlobStorage": { + "ConnectionString": "" + }, + + "AllowedHosts": "*" +} diff --git a/ChatService.sln b/ChatService.sln new file mode 100644 index 0000000..d096d46 --- /dev/null +++ b/ChatService.sln @@ -0,0 +1,28 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatService.Web", "ChatService.Web\ChatService.Web.csproj", "{61F5D341-3097-447A-AC42-28C9664BC863}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatService.Web.Tests", "ChatService.Web.Tests\ChatService.Web.Tests.csproj", "{270C3052-C9D2-4EF9-9683-D5E4A4E69733}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatService.Web.IntegrationTests", "ChatService.Web.IntegrationTests\ChatService.Web.IntegrationTests.csproj", "{A7808CB8-B561-4939-9B4E-8AC0FD786DC3}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {61F5D341-3097-447A-AC42-28C9664BC863}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {61F5D341-3097-447A-AC42-28C9664BC863}.Debug|Any CPU.Build.0 = Debug|Any CPU + {61F5D341-3097-447A-AC42-28C9664BC863}.Release|Any CPU.ActiveCfg = Release|Any CPU + {61F5D341-3097-447A-AC42-28C9664BC863}.Release|Any CPU.Build.0 = Release|Any CPU + {270C3052-C9D2-4EF9-9683-D5E4A4E69733}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {270C3052-C9D2-4EF9-9683-D5E4A4E69733}.Debug|Any CPU.Build.0 = Debug|Any CPU + {270C3052-C9D2-4EF9-9683-D5E4A4E69733}.Release|Any CPU.ActiveCfg = Release|Any CPU + {270C3052-C9D2-4EF9-9683-D5E4A4E69733}.Release|Any CPU.Build.0 = Release|Any CPU + {A7808CB8-B561-4939-9B4E-8AC0FD786DC3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A7808CB8-B561-4939-9B4E-8AC0FD786DC3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A7808CB8-B561-4939-9B4E-8AC0FD786DC3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A7808CB8-B561-4939-9B4E-8AC0FD786DC3}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/ChatService.sln.DotSettings.user b/ChatService.sln.DotSettings.user new file mode 100644 index 0000000..81131c0 --- /dev/null +++ b/ChatService.sln.DotSettings.user @@ -0,0 +1,30 @@ + + INFO + On + On + On + C:\Users\nadim\AppData\Local\JetBrains\Rider2022.3\resharper-host\temp\Rider\vAny\CoverageData\_ChatService.34854384\Snapshot\snapshot.utdcvr + <SessionState ContinuousTestingMode="0" Name="ImagesControllerTests" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Solution /> +</SessionState> + + + + <SessionState ContinuousTestingMode="0" IsActive="True" Name="ProfileControllerTests" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Solution /> +</SessionState> + + + + DefaultEndpointsProtocol=https;AccountName=naa137;AccountKey=p6VJPQjkAPm3/JxBIY1l7Yj9ewb4Fp2l+Aciwm969OgT57+70hT/jMKcyDIdIwIJaEijfqsxAOdf+AStE/MZDw==;EndpointSuffix=core.windows.net + + + AccountEndpoint=https://chat-service.documents.azure.com:443/;AccountKey=bTPHlotBT0IYyYa4sFscBdFm3dzVpTexbtk8ygqhytijFYLpJAByf5CThrODbuDVvmTOcDio2ywEACDbCYlcOg==; + + + + + + + + \ No newline at end of file