diff --git a/.github/workflows/prbuild.yml b/.github/workflows/prbuild.yml new file mode 100644 index 0000000..f95552d --- /dev/null +++ b/.github/workflows/prbuild.yml @@ -0,0 +1,40 @@ +# This workflow will build a .NET project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net + +name: .NET + +on: + workflow_dispatch: + pull_request: + branches: [ "main" ] + +jobs: + build: + runs-on: ubuntu-latest + + env: + BlobStorage:ConnectionString: "${{ secrets.BLOBSTORAGE_CONNECTIONSTRING}}" + Cosmos:ConnectionString: "${{ secrets.COSMOS_CONNECTIONSTRING}}" + ServiceBus:ConnectionString: "${{ secrets.SERVICEBUS_CONNECTIONSTRING}}" + SQL:ConnectionString: "${{ secrets.SQL_CONNECTIONSTRING}}" + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 7.0.x + + - name: Restore dependencies + run: dotnet restore ./ChatApplication/ChatApplication.sln + + - name: Build + run: dotnet build ./ChatApplication/ChatApplication.sln --configuration Release --no-restore + + - name: Run unit tests + run: dotnet test ./ChatApplication/ChatApplication.Web.Tests/bin/Release/net7.0/ChatApplication.Web.Tests.dll + + - name: Run integration tests + run: dotnet test ./ChatApplication/ChatApplication.Web.IntegrationTests/bin/Release/net7.0/ChatApplication.Web.IntegrationTests.dll diff --git a/.github/workflows/prdeploy.yml b/.github/workflows/prdeploy.yml new file mode 100644 index 0000000..9ff37d3 --- /dev/null +++ b/.github/workflows/prdeploy.yml @@ -0,0 +1,41 @@ +name: Build, Test and Deploy to Azure + +on: + workflow_dispatch: + push: + branches: + - main + +env: + AZURE_WEBAPP_NAME: chatservice-jad-ronald + 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: '7.0.x' + + - name: Restore dependencies + run: dotnet restore ./ChatApplication/ChatApplication.sln + + - name: Build + run: dotnet build ./ChatApplication/ChatApplication.sln --configuration Release --no-restore + + - name: Publish + run: dotnet publish --configuration Release --output '${{ env.AZURE_WEBAPP_PACKAGE_PATH }}' --no-restore ./ChatApplication/ChatApplication.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/.gitignore b/.gitignore new file mode 100644 index 0000000..d531f81 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# User-specific stuff +.idea +*.user + +### DotnetCore ### +# .NET Core build folders +bin/ +obj/ diff --git a/ChatApplication/.vs/ChatApplication/DesignTimeBuild/.dtbcache.v2 b/ChatApplication/.vs/ChatApplication/DesignTimeBuild/.dtbcache.v2 new file mode 100644 index 0000000..9a175dd Binary files /dev/null and b/ChatApplication/.vs/ChatApplication/DesignTimeBuild/.dtbcache.v2 differ diff --git a/ChatApplication/.vs/ChatApplication/FileContentIndex/523e2fb9-5e96-4445-b65d-a74576698f27.vsidx b/ChatApplication/.vs/ChatApplication/FileContentIndex/523e2fb9-5e96-4445-b65d-a74576698f27.vsidx new file mode 100644 index 0000000..ce118d2 Binary files /dev/null and b/ChatApplication/.vs/ChatApplication/FileContentIndex/523e2fb9-5e96-4445-b65d-a74576698f27.vsidx differ diff --git a/ChatApplication/.vs/ChatApplication/FileContentIndex/ff81a298-06bc-46b9-be99-6d11d5e9e197.vsidx b/ChatApplication/.vs/ChatApplication/FileContentIndex/ff81a298-06bc-46b9-be99-6d11d5e9e197.vsidx new file mode 100644 index 0000000..38f4140 Binary files /dev/null and b/ChatApplication/.vs/ChatApplication/FileContentIndex/ff81a298-06bc-46b9-be99-6d11d5e9e197.vsidx differ diff --git a/ChatApplication/.vs/ChatApplication/FileContentIndex/read.lock b/ChatApplication/.vs/ChatApplication/FileContentIndex/read.lock new file mode 100644 index 0000000..e69de29 diff --git a/ChatApplication/.vs/ChatApplication/config/applicationhost.config b/ChatApplication/.vs/ChatApplication/config/applicationhost.config new file mode 100644 index 0000000..0d88f0d --- /dev/null +++ b/ChatApplication/.vs/ChatApplication/config/applicationhost.config @@ -0,0 +1,1016 @@ + + + + + + + +
+
+
+
+
+
+
+
+ + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+ +
+
+ +
+
+ +
+
+
+ + +
+
+
+
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ChatApplication/.vs/ChatApplication/v17/.futdcache.v2 b/ChatApplication/.vs/ChatApplication/v17/.futdcache.v2 new file mode 100644 index 0000000..05fbc40 Binary files /dev/null and b/ChatApplication/.vs/ChatApplication/v17/.futdcache.v2 differ diff --git a/ChatApplication/.vs/ChatApplication/v17/.suo b/ChatApplication/.vs/ChatApplication/v17/.suo new file mode 100644 index 0000000..28bce74 Binary files /dev/null and b/ChatApplication/.vs/ChatApplication/v17/.suo differ diff --git a/ChatApplication/.vs/ChatApplication/v17/TestStore/0/000.testlog b/ChatApplication/.vs/ChatApplication/v17/TestStore/0/000.testlog new file mode 100644 index 0000000..6bbf10e Binary files /dev/null and b/ChatApplication/.vs/ChatApplication/v17/TestStore/0/000.testlog differ diff --git a/ChatApplication/.vs/ChatApplication/v17/TestStore/0/007.testlog b/ChatApplication/.vs/ChatApplication/v17/TestStore/0/007.testlog new file mode 100644 index 0000000..cdcbf6b Binary files /dev/null and b/ChatApplication/.vs/ChatApplication/v17/TestStore/0/007.testlog differ diff --git a/ChatApplication/.vs/ChatApplication/v17/TestStore/0/testlog.manifest b/ChatApplication/.vs/ChatApplication/v17/TestStore/0/testlog.manifest new file mode 100644 index 0000000..006f9b0 Binary files /dev/null and b/ChatApplication/.vs/ChatApplication/v17/TestStore/0/testlog.manifest differ diff --git a/ChatApplication/.vs/ProjectEvaluation/chatapplication.metadata.v5.2 b/ChatApplication/.vs/ProjectEvaluation/chatapplication.metadata.v5.2 new file mode 100644 index 0000000..3ac60d2 Binary files /dev/null and b/ChatApplication/.vs/ProjectEvaluation/chatapplication.metadata.v5.2 differ diff --git a/ChatApplication/.vs/ProjectEvaluation/chatapplication.metadata.v6.1 b/ChatApplication/.vs/ProjectEvaluation/chatapplication.metadata.v6.1 new file mode 100644 index 0000000..bd6249b Binary files /dev/null and b/ChatApplication/.vs/ProjectEvaluation/chatapplication.metadata.v6.1 differ diff --git a/ChatApplication/.vs/ProjectEvaluation/chatapplication.projects.v5.2 b/ChatApplication/.vs/ProjectEvaluation/chatapplication.projects.v5.2 new file mode 100644 index 0000000..fa60ec4 Binary files /dev/null and b/ChatApplication/.vs/ProjectEvaluation/chatapplication.projects.v5.2 differ diff --git a/ChatApplication/.vs/ProjectEvaluation/chatapplication.projects.v6.1 b/ChatApplication/.vs/ProjectEvaluation/chatapplication.projects.v6.1 new file mode 100644 index 0000000..f2a893b Binary files /dev/null and b/ChatApplication/.vs/ProjectEvaluation/chatapplication.projects.v6.1 differ diff --git a/ChatApplication/ChatApplication.Web.IntegrationTests/BlobImageStoreTests.cs b/ChatApplication/ChatApplication.Web.IntegrationTests/BlobImageStoreTests.cs new file mode 100644 index 0000000..46b4113 --- /dev/null +++ b/ChatApplication/ChatApplication.Web.IntegrationTests/BlobImageStoreTests.cs @@ -0,0 +1,103 @@ +using System.Runtime.InteropServices; +using ChatApplication.Exceptions; +using ChatApplication.Storage; +using ChatApplication.Utils; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace ChatApplication.Web.IntegrationTests; + +public class BlobImageStoreTests : IClassFixture>, IAsyncLifetime +{ + private readonly IImageStore _store; + private readonly string blobName = Guid.NewGuid().ToString(); + private readonly MemoryStream _data = new(new byte[] { 1, 2, 3 }); + private readonly string _contentType = "image/png"; + + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + await _store.DeleteImage(blobName); + } + + public BlobImageStoreTests(WebApplicationFactory factory) + { + _store = factory.Services.GetRequiredService(); + } + + [Fact] + + public async Task AddImage_Success() + { + await _store.AddImage(blobName, _data, _contentType); + var actual = await _store.GetImage(blobName); + var actualData = new MemoryStream(actual!.ImageData); + Assert.Equal(_data.ToArray(), actualData.ToArray()); + Assert.Equal(_contentType, actual.ContentType); + } + + [Theory] + [InlineData(null, new byte[0], "image/jpeg")] + [InlineData("", new byte[0], "image/jpeg")] + [InlineData(" ", new byte[0], "image/jpeg")] + [InlineData("foobar", new byte[0], "image/jpeg")] + [InlineData("foobar", new byte[0], "image/pdf")] + [InlineData("foobar", new byte[0], "")] + [InlineData("foobar", new byte[0], " ")] + [InlineData("foobar", new byte[0], null)] + + public async Task AddImage_InvalidArgs(string blobName, byte[] data, string contentType) + { + var stream = new MemoryStream(data); + await Assert.ThrowsAsync(() => _store.AddImage(blobName, stream, contentType)); + } + + [Fact] + + public async Task GetImage_NotFound() + { + await Assert.ThrowsAsync( + async () => await _store.GetImage("foobar")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task GetImage_InvalidArgs(string id) + { + await Assert.ThrowsAsync(async () => + { + await _store.GetImage(id); + }); + } + + [Fact] + + public async Task DeleteImage_Success() + { + + await _store.AddImage(blobName, _data, _contentType); + await _store.DeleteImage(blobName); + await Assert.ThrowsAsync(async () => + { + await _store.GetImage(blobName); + }); + } + + [Fact] + + public async Task DeleteImage_EmptyImage() + { + await Assert.ThrowsAsync(async () => + { + await _store.DeleteImage(""); + }); + } + + +} diff --git a/ChatApplication/ChatApplication.Web.IntegrationTests/ChatApplication.Web.IntegrationTests.csproj b/ChatApplication/ChatApplication.Web.IntegrationTests/ChatApplication.Web.IntegrationTests.csproj new file mode 100644 index 0000000..f425d51 --- /dev/null +++ b/ChatApplication/ChatApplication.Web.IntegrationTests/ChatApplication.Web.IntegrationTests.csproj @@ -0,0 +1,34 @@ + + + + net7.0 + enable + enable + + false + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/ChatApplication/ChatApplication.Web.IntegrationTests/CosmosConversationStoreTests.cs b/ChatApplication/ChatApplication.Web.IntegrationTests/CosmosConversationStoreTests.cs new file mode 100644 index 0000000..9f1be2d --- /dev/null +++ b/ChatApplication/ChatApplication.Web.IntegrationTests/CosmosConversationStoreTests.cs @@ -0,0 +1,257 @@ +using ChatApplication.Configuration; +using ChatApplication.Exceptions; +using ChatApplication.Storage; +using ChatApplication.Web.Dtos; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Azure.Cosmos; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace ChatApplication.Web.IntegrationTests; + +public class CosmosConversationStoreTests : IClassFixture>, IAsyncLifetime + +{ + private readonly IConversationStore _store; + private readonly Profile _profile1 = new Profile(Guid.NewGuid().ToString(), "king", "97", "123"); + private readonly Profile _profile2 = new Profile(Guid.NewGuid().ToString(), "ok", "noob", "1234"); + private readonly Profile _profile3 = new Profile(Guid.NewGuid().ToString(), "k", "rim", "12345"); + private readonly Profile _profile4 = new Profile(Guid.NewGuid().ToString(), "k", "rim", "123456"); + private readonly UserConversation _conversation1; + private readonly UserConversation _conversation2; + private readonly UserConversation _conversation3; + private readonly List _conversationList; + + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + foreach (var conversation in _conversationList) + { + await _store.DeleteUserConversation(conversation); + } + } + + public CosmosConversationStoreTests(WebApplicationFactory factory) + { + List recipients1 = new() { _profile2 }; + List recipients2 = new() { _profile3 }; + List recipients3 = new() { _profile4 }; + _conversation1 = new UserConversation(Guid.NewGuid().ToString(), recipients1, 1002, _profile1.Username); + _conversation2 = new UserConversation(Guid.NewGuid().ToString(), recipients2, 1001, _profile1.Username); + _conversation3 = new UserConversation(Guid.NewGuid().ToString(), recipients3, 1000, _profile1.Username); + _conversationList = new List(){_conversation1, _conversation2, _conversation3}; + + var services = factory.Services; + var cosmosSettings = services.GetRequiredService>().Value; + var cosmosClient = new CosmosClient(cosmosSettings.ConnectionString); + _store = new CosmosConversationStore(cosmosClient); + } + + + [Fact] + + public async Task GetUserConversation_Success() + { + await _store.CreateUserConversation(_conversationList[0]); + var conversation = await _store.GetUserConversation(_conversationList[0].Username, _conversationList[0].ConversationId); + Assert.Equivalent(_conversationList[0], conversation); + } + + [Fact] + + public async Task GetUserConversation_NotFoundUsername() + { + await Assert.ThrowsAsync(async () => + { + var randomId = Guid.NewGuid().ToString(); + await _store.GetUserConversation(randomId, _conversationList[0].ConversationId); + }); + } + + [Fact] + + public async Task GetUserConversation_NotFoundConversationId() + { + await Assert.ThrowsAsync(async () => + { + var randomId = Guid.NewGuid().ToString(); + await _store.GetUserConversation(_conversationList[0].Username, randomId); + }); + } + + [Fact] + + public async Task GetUserConversation_EmptyConversationId() + { + await Assert.ThrowsAsync(async () => + { + await _store.GetUserConversation(_conversationList[0].Username, ""); + }); + } + + [Fact] + + public async Task UpdateConversationLastMessageTime_Success() + { + var receiverConversation = new UserConversation(_conversationList[0].ConversationId, new List{_profile1}, _conversationList[0].LastMessageTime, _conversationList[0].Recipients[0].Username); + var senderConversation = _conversationList[0]; + await _store.CreateUserConversation(senderConversation); + await _store.CreateUserConversation(receiverConversation); + await _store.UpdateConversationLastMessageTime(senderConversation, 1005); + + var senderConversationAfterUpdate = await _store.GetUserConversation(senderConversation.Username,senderConversation.ConversationId); + var receiverConversationAfterUpdate = await _store.GetUserConversation(receiverConversation.Username, receiverConversation.ConversationId); + + Assert.Equal(1005, senderConversationAfterUpdate.LastMessageTime); + Assert.Equal(1005, receiverConversationAfterUpdate.LastMessageTime); + } + + [Fact] + + public async Task UpdateConversationLastMessageTime_ConversationNotFound() + { + + await Assert.ThrowsAsync(async () => + { + await _store.UpdateConversationLastMessageTime(_conversationList[0], 1005); + }); + } + + + [Fact] + + public async Task CreateUserConversation_Success() + { + await _store.CreateUserConversation(_conversationList[0]); + var conversation = await _store.GetUserConversation(_conversationList[0].Username, _conversationList[0].ConversationId); + Assert.Equivalent(_conversationList[0], conversation); + } + + [Fact] + + public async Task CreateUserConversation_Conflict() + { + await _store.CreateUserConversation(_conversationList[0]); + await Assert.ThrowsAsync(async () => + { + await _store.CreateUserConversation(_conversationList[0]); + }); + } + + [Fact] + + public async Task CreateUserConversation_EmptyId() + { + var conversation = new UserConversation("", new List(), 1000, _conversationList[0].ConversationId); + await Assert.ThrowsAsync(async () => + { + await _store.CreateUserConversation(conversation); + }); + } + + + [Fact] + + public async Task DeleteUserConversation_Success() + { + await _store.CreateUserConversation(_conversationList[0]); + await _store.DeleteUserConversation(_conversationList[0]); + await Assert.ThrowsAsync(async () => + { + await _store.GetUserConversation(_conversationList[0].Username, _conversationList[0].ConversationId); + }); + } + + [Fact] + + public async Task DeleteUserConversation_EmptyId() + { + var conversation = new UserConversation("", new List(), 1000, _conversationList[0].ConversationId); + await Assert.ThrowsAsync(async () => + { + await _store.DeleteUserConversation(conversation); + }); + } + + [Fact] + + public async Task GetAllConversations_Success() + { + var expected = new List(); + foreach (var conversation in _conversationList) + { + await _store.CreateUserConversation(conversation); + expected.Add(conversation); + } + + var parameters = new GetConversationsParameters(_conversationList[0].Username, 100, "", 0); + var actual = await _store.GetConversations(parameters); + Assert.Equivalent(expected, actual.Conversations); + } + + + [Fact] + public async Task GetConversationMessages_WithContinuationToken() + { + foreach (var conversation in _conversationList) + { + await _store.CreateUserConversation(conversation); + } + + var parametersInitialCall = new GetConversationsParameters(_conversationList[0].Username, 2, "", 0); + var actualInitialCall = await _store.GetConversations(parametersInitialCall); + Assert.Equivalent(_conversationList[0], actualInitialCall.Conversations[0]); + Assert.Equivalent(_conversationList[1], actualInitialCall.Conversations[1]); + var parametersSecondCall = new GetConversationsParameters(_conversationList[0].Username, 2, actualInitialCall.ContinuationToken, 0); + var actualSecondCall = await _store.GetConversations(parametersSecondCall); + Assert.Equivalent(_conversationList[2], actualSecondCall.Conversations[0]); + } + + [Theory] + [InlineData(0, 1)] + [InlineData(-1, 1)] + [InlineData(150, 3)] + [InlineData(2, 2)] + [InlineData(null, 1)] + public async Task GetConversationMessages_WithBadLimit(int limit, int actualCount) + { + foreach (var conversation in _conversationList) + { + await _store.CreateUserConversation(conversation); + } + var parameters = new GetConversationsParameters(_conversationList[0].Username, limit, "", 0); + var actual = await _store.GetConversations(parameters); + Assert.Equal(actualCount, actual.Conversations.Count); + } + + [Fact] + public async Task GetConversationMessages_WithBadContinuationToken() + { + foreach (var conversation in _conversationList) + { + await _store.CreateUserConversation(conversation); + } + await Assert.ThrowsAsync( async () => + { + var parameters = new GetConversationsParameters(_conversationList[0].Username, 100, "bad token", 0); + var actual = await _store.GetConversations(parameters); + }); + } + + [Fact] + public async Task GetConversationMessages_WithUnixTime() + { + foreach (var conversation in _conversationList) + { + await _store.CreateUserConversation(conversation); + } + var parameters = new GetConversationsParameters(_conversationList[0].Username, 100, "", 1000); + var actual = await _store.GetConversations(parameters); + Assert.Equivalent(_conversationList[0], actual.Conversations[0]); + Assert.Equivalent(_conversationList[1], actual.Conversations[1]); + } +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web.IntegrationTests/CosmosMessageStoreTests.cs b/ChatApplication/ChatApplication.Web.IntegrationTests/CosmosMessageStoreTests.cs new file mode 100644 index 0000000..16492c9 --- /dev/null +++ b/ChatApplication/ChatApplication.Web.IntegrationTests/CosmosMessageStoreTests.cs @@ -0,0 +1,194 @@ +using ChatApplication.Configuration; +using ChatApplication.Exceptions; +using ChatApplication.Storage; +using ChatApplication.Web.Dtos; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Azure.Cosmos; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace ChatApplication.Web.IntegrationTests; + +public class CosmosMessageStoreTests : IClassFixture>, IAsyncLifetime +{ + private readonly IMessageStore _store; + private readonly string _conversationId; + private readonly Message _message1; + private readonly Message _message2; + private readonly Message _message3; + private readonly ConversationMessage _conversationMessage1 = new ConversationMessage("ronald", "hello", 1002); + private readonly ConversationMessage _conversationMessage2 = new ConversationMessage("ronald", "hello", 1001); + private readonly ConversationMessage _conversationMessage3 = new ConversationMessage("ronald", "hello", 1000); + private readonly List _conversationMessageList; + private readonly List _messageList; + + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + foreach (var message in _messageList) + { + await _store.DeleteMessage(message); + } + } + + public CosmosMessageStoreTests(WebApplicationFactory factory) + { + + _conversationId = Guid.NewGuid().ToString(); + _message1 = new Message(Guid.NewGuid().ToString(), "ronald", _conversationId, "hello", 1002); + _message2 = new Message(Guid.NewGuid().ToString(), "ronald", _conversationId, "hello", 1001); + _message3 = new Message(Guid.NewGuid().ToString(), "ronald", _conversationId, "hello", 1000); + + _messageList = new List() { _message1, _message2, _message3 }; + _conversationMessageList = new List() + { _conversationMessage1, _conversationMessage2, _conversationMessage3 }; + + var services = factory.Services; + var cosmosSettings = services.GetRequiredService>().Value; + var cosmosClient = new CosmosClient(cosmosSettings.ConnectionString); + _store = new CosmosMessageStore(cosmosClient); + } + + [Fact] + public async Task AddMessage_Success() + { + await _store.AddMessage(_messageList[0]); + var parameters = new GetMessagesParameters(_messageList[0].ConversationId, 100, "", 0); + var actual = await _store.GetMessages(parameters); + + Assert.Equal(_conversationMessageList[0], actual.Messages[0]); + } + + [Fact] + public async Task AddMessage_MessageAlreadyExists() + { + await _store.AddMessage(_messageList[0]); + await Assert.ThrowsAsync(async () => + { + await _store.AddMessage(_messageList[0]); + }); + } + + [Fact] + + public async Task GetMessage_Success() + { + await _store.AddMessage(_messageList[0]); + var actual = await _store.GetMessage(_messageList[0].ConversationId, _messageList[0].MessageId); + + Assert.Equal(_messageList[0], actual); + } + + [Fact] + + public async Task GetMessage_NotFound() + { + await Assert.ThrowsAsync(async () => + { + await _store.GetMessage(_messageList[0].ConversationId, _messageList[0].MessageId); + }); + } + + + [Fact] + public async Task DeleteMessage_Success() + { + await _store.AddMessage(_messageList[0]); + await _store.DeleteMessage(_messageList[0]); + var parameters = new GetMessagesParameters(_messageList[0].ConversationId, 100, "", 0); + var actual = await _store.GetMessages(parameters); + Assert.Empty(actual.Messages); + } + + [Fact] + public async Task DeleteMessage_EmptyMessage() + { + var message = new Message("", "", "", "", 1); + await Assert.ThrowsAsync(async () => { await _store.DeleteMessage(message); }); + } + + [Fact] + public async Task GetConversationMessages_Success() + { + var expected = new List(); + foreach (var message in _messageList) + { + await _store.AddMessage(message); + expected.Add(new ConversationMessage(message.SenderUsername, message.Text, + message.CreatedUnixTime)); + } + var parameters = new GetMessagesParameters(_messageList[0].ConversationId, 100, "", 0); + var actual = await _store.GetMessages(parameters); + Assert.Equal(expected, actual.Messages); + } + + + [Fact] + public async Task GetConversationMessages_WithContinuationToken() + { + foreach (var message in _messageList) + { + await _store.AddMessage(message); + } + var parametersFirstCall = new GetMessagesParameters(_messageList[0].ConversationId, 2, "", 0); + var actual = await _store.GetMessages(parametersFirstCall); + Assert.Equal(_conversationMessageList[0], actual.Messages[0]); + Assert.Equal(_conversationMessageList[1], actual.Messages[1]); + var parametersSecondCall = new GetMessagesParameters(_messageList[0].ConversationId, 2, + actual.ContinuationToken, 0); + var actual2 = + await _store.GetMessages(parametersSecondCall); + Assert.Equal(_conversationMessageList[2], actual2.Messages[0]); + } + + + [Theory] + [InlineData(0, 1)] + [InlineData(-1, 1)] + [InlineData(150, 3)] + [InlineData(2, 2)] + [InlineData(null, 1)] + public async Task GetConversationMessages_WithBadLimit(int limit, int actualCount) + { + foreach (var message in _messageList) + { + await _store.AddMessage(message); + } + var parameters = new GetMessagesParameters(_messageList[0].ConversationId, limit, "", 0); + var actual = await _store.GetMessages(parameters); + Assert.Equal(actualCount, actual.Messages.Count); + } + + [Fact] + public async Task GetConversationMessages_WithBadContinuationToken() + { + foreach (var message in _messageList) + { + await _store.AddMessage(message); + } + + await Assert.ThrowsAsync(async () => + { + var parameters = new GetMessagesParameters(_messageList[0].ConversationId, 100, "bad token", 0); + var actual = await _store.GetMessages(parameters); + }); + } + + [Fact] + public async Task GetConversationMessages_WithUnixTime() + { + foreach (var message in _messageList) + { + await _store.AddMessage(message); + } + + var parameters = new GetMessagesParameters(_messageList[0].ConversationId, 100, "", 1000); + var actual = await _store.GetMessages(parameters); + Assert.Equal(_conversationMessageList[0], actual.Messages[0]); + Assert.Equal(_conversationMessageList[1], actual.Messages[1]); + } +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web.IntegrationTests/CosmosProfileStoreTests.cs b/ChatApplication/ChatApplication.Web.IntegrationTests/CosmosProfileStoreTests.cs new file mode 100644 index 0000000..48cc709 --- /dev/null +++ b/ChatApplication/ChatApplication.Web.IntegrationTests/CosmosProfileStoreTests.cs @@ -0,0 +1,131 @@ +using ChatApplication.Configuration; +using ChatApplication.Exceptions; +using ChatApplication.Storage; +using ChatApplication.Web.Dtos; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Azure.Cosmos; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace ChatApplication.Web.IntegrationTests; + +public class CosmosProfileStoreTests:IClassFixture>, IAsyncLifetime +{ + + private readonly IProfileStore _store; + private readonly Profile _profile = new( + Username: Guid.NewGuid().ToString(), + FirstName: "Foo", + LastName: "Bar", + ProfilePictureId: "123" + ); + + public CosmosProfileStoreTests(WebApplicationFactory factory) + { + var services = factory.Services; + var cosmosSettings = services.GetRequiredService>().Value; + var cosmosClient = new CosmosClient(cosmosSettings.ConnectionString); + _store = new CosmosProfileStore(cosmosClient); + } + + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + await _store.DeleteProfile(_profile.Username); + } + + [Fact] + + public async Task AddProfile_Success() + { + await _store.AddProfile(_profile); + Assert.Equal(_profile, await _store.GetProfile(_profile.Username)); + } + + [Fact] + public async Task GetProfile_NotFound() + { + await Assert.ThrowsAsync(async () => await _store.GetProfile(_profile.Username + "1")); + + } + + [Fact] + + public async Task GetProfile_EmptyProfile() + { + await Assert.ThrowsAsync(async () => + { + await _store.GetProfile(""); + }); + } + + + [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")] + public async Task AddProfile_InvalidArgs(string username, string firstName, string lastName, + string profilePictureId) + { + await Assert.ThrowsAsync(async () => + { + await _store.AddProfile(new Profile(username, firstName, lastName, profilePictureId)); + + }); + + } + + [Fact] + + public async Task AddProfile_NoImage() + { + var profile = new Profile(Guid.NewGuid().ToString(), "Foo", "Bar"); + await _store.AddProfile(profile); + var returnedProfile = await _store.GetProfile(profile.Username); + await _store.DeleteProfile(profile.Username); + Assert.Equal(profile, returnedProfile); + + } + + [Fact] + + public async Task DeleteProfile_Success() + { + await _store.AddProfile(_profile); + await _store.DeleteProfile(_profile.Username); + await Assert.ThrowsAsync(async()=> await _store.GetProfile(_profile.Username)); + } + + [Fact] + + public async Task DeleteProfile_EmptyProfile() + { + await Assert.ThrowsAsync(async () => + { + await _store.DeleteProfile(""); + }); + } + + + [Fact] + + public async Task AddProfile_Conflict() + { + await _store.AddProfile(_profile); + await Assert.ThrowsAsync(async () => + { + await _store.AddProfile(_profile); + }); + } + +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web.IntegrationTests/SQLConversationStoreTests.cs b/ChatApplication/ChatApplication.Web.IntegrationTests/SQLConversationStoreTests.cs new file mode 100644 index 0000000..35155aa --- /dev/null +++ b/ChatApplication/ChatApplication.Web.IntegrationTests/SQLConversationStoreTests.cs @@ -0,0 +1,258 @@ +using ChatApplication.Configuration; +using ChatApplication.Exceptions; +using ChatApplication.Storage; +using ChatApplication.Storage.SQL; +using ChatApplication.Web.Dtos; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Azure.Cosmos; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace ChatApplication.Web.IntegrationTests; + +public class SQLConversationStoreTests: IClassFixture>, IAsyncLifetime +{ + private readonly IConversationStore _conversationStore; + private readonly IProfileStore _profileStore; + private readonly Profile _profile1 = new Profile(Guid.NewGuid().ToString(), "king", "97", "123"); + private readonly Profile _profile2 = new Profile(Guid.NewGuid().ToString(), "ok", "noob", "1234"); + private readonly Profile _profile3 = new Profile(Guid.NewGuid().ToString(), "k", "rim", "12345"); + private readonly Profile _profile4 = new Profile(Guid.NewGuid().ToString(), "k", "rim", "123456"); + private readonly UserConversation _conversation1; + private readonly UserConversation _conversation2; + private readonly UserConversation _conversation3; + private readonly List _conversationList; + + public async Task InitializeAsync() + { + await _profileStore.AddProfile(_profile1); + await _profileStore.AddProfile(_profile2); + await _profileStore.AddProfile(_profile3); + await _profileStore.AddProfile(_profile4); + } + + public async Task DisposeAsync() + { + foreach(var profile in new List(){_profile1, _profile2, _profile3, _profile4}) + { + try + { + await _profileStore.DeleteProfile(profile.Username); + } + catch + { + + } + } + } + + + public SQLConversationStoreTests(WebApplicationFactory factory) + { + List recipients1 = new() { _profile2 }; + List recipients2 = new() { _profile3 }; + List recipients3 = new() { _profile4 }; + + _conversation1 = new UserConversation(Guid.NewGuid().ToString(), recipients1, 1002, _profile1.Username); + _conversation2 = new UserConversation(Guid.NewGuid().ToString(), recipients2, 1001, _profile1.Username); + _conversation3 = new UserConversation(Guid.NewGuid().ToString(), recipients3, 1000, _profile1.Username); + + _conversationList = new List(){_conversation1, _conversation2, _conversation3}; + + var services = factory.Services; + var sqlSettings = services.GetRequiredService>(); + _conversationStore = new SQLConversationStore(sqlSettings); + _profileStore = new SQLProfileStore(sqlSettings); + } + + [Fact] + public async Task GetUserConversation_Success() + { + await _conversationStore.CreateUserConversation(_conversationList[0]); + var conversation = await _conversationStore.GetUserConversation(_conversationList[0].Username, _conversationList[0].ConversationId); + Assert.Equivalent(_conversationList[0], conversation); + } + + [Fact] + public async Task GetUserConversation_NotFoundUsername() + { + await Assert.ThrowsAsync(async () => + { + var randomId = Guid.NewGuid().ToString(); + await _conversationStore.GetUserConversation(randomId, _conversationList[0].ConversationId); + }); + } + + [Fact] + public async Task GetUserConversation_NotFoundConversationId() + { + await Assert.ThrowsAsync(async () => + { + var randomId = Guid.NewGuid().ToString(); + await _conversationStore.GetUserConversation(_conversationList[0].Username, randomId); + }); + } + + [Fact] + + public async Task GetUserConversation_EmptyConversationId() + { + await Assert.ThrowsAsync(async () => + { + await _conversationStore.GetUserConversation(_conversationList[0].Username, ""); + }); + } + + [Fact] + public async Task UpdateConversationLastMessageTime_Success() + { + var receiverConversation = new UserConversation(_conversationList[0].ConversationId, new List{_profile1}, _conversationList[0].LastMessageTime, _conversationList[0].Recipients[0].Username); + var senderConversation = _conversationList[0]; + + await _conversationStore.CreateUserConversation(senderConversation); + await _conversationStore.CreateUserConversation(receiverConversation); + await _conversationStore.UpdateConversationLastMessageTime(senderConversation, 1005); + + var senderConversationAfterUpdate = await _conversationStore.GetUserConversation(senderConversation.Username,senderConversation.ConversationId); + var receiverConversationAfterUpdate = await _conversationStore.GetUserConversation(receiverConversation.Username, receiverConversation.ConversationId); + + Assert.Equal(1005, senderConversationAfterUpdate.LastMessageTime); + Assert.Equal(1005, receiverConversationAfterUpdate.LastMessageTime); + } + + [Fact] + + public async Task UpdateConversationLastMessageTime_ConversationNotFound() + { + + await Assert.ThrowsAsync(async () => + { + await _conversationStore.UpdateConversationLastMessageTime(_conversationList[0], 1005); + }); + } + + [Fact] + + public async Task CreateUserConversation_Success() + { + await _conversationStore.CreateUserConversation(_conversationList[0]); + + var conversation = await _conversationStore.GetUserConversation(_conversationList[0].Username, _conversationList[0].ConversationId); + + Assert.Equivalent(_conversationList[0], conversation); + } + + + [Fact] + + public async Task CreateUserConversation_EmptyId() + { + var conversation = new UserConversation("", new List(), 1000, _conversationList[0].ConversationId); + + await Assert.ThrowsAsync(async () => + { + await _conversationStore.CreateUserConversation(conversation); + }); + } + + + [Fact] + + public async Task DeleteUserConversation_Success() + { + await _conversationStore.CreateUserConversation(_conversationList[0]); + await _conversationStore.DeleteUserConversation(_conversationList[0]); + + await Assert.ThrowsAsync(async () => + { + await _conversationStore.GetUserConversation(_conversationList[0].Username, _conversationList[0].ConversationId); + }); + } + + [Fact] + + public async Task GetAllConversations_Success() + { + var expected = new List(); + foreach (var conversation in _conversationList) + { + await _conversationStore.CreateUserConversation(conversation); + expected.Add(conversation); + } + + var parameters = new GetConversationsParameters(_conversationList[0].Username, 100, "", 0); + var actual = await _conversationStore.GetConversations(parameters); + + Assert.Equivalent(expected, actual.Conversations); + } + + + [Fact] + public async Task GetConversationMessages_WithContinuationToken() + { + foreach (var conversation in _conversationList) + { + await _conversationStore.CreateUserConversation(conversation); + } + + var parametersInitialCall = new GetConversationsParameters(_conversationList[0].Username, 2, "", 0); + var actualInitialCall = await _conversationStore.GetConversations(parametersInitialCall); + + Assert.Equivalent(_conversationList[0], actualInitialCall.Conversations[0]); + Assert.Equivalent(_conversationList[1], actualInitialCall.Conversations[1]); + + var parametersSecondCall = new GetConversationsParameters(_conversationList[0].Username, 2, actualInitialCall.ContinuationToken, 0); + var actualSecondCall = await _conversationStore.GetConversations(parametersSecondCall); + + Assert.Equivalent(_conversationList[2], actualSecondCall.Conversations[0]); + } + + [Theory] + [InlineData(0, 1)] + [InlineData(-1, 1)] + [InlineData(150, 3)] + [InlineData(2, 2)] + [InlineData(null, 1)] + public async Task GetConversationMessages_WithBadLimit(int limit, int actualCount) + { + foreach (var conversation in _conversationList) + { + await _conversationStore.CreateUserConversation(conversation); + } + + var parameters = new GetConversationsParameters(_conversationList[0].Username, limit, "", 0); + var actual = await _conversationStore.GetConversations(parameters); + + Assert.Equal(actualCount, actual.Conversations.Count); + } + + [Fact] + public async Task GetConversationMessages_WithBadContinuationToken() + { + foreach (var conversation in _conversationList) + { + await _conversationStore.CreateUserConversation(conversation); + } + await Assert.ThrowsAsync( async () => + { + var parameters = new GetConversationsParameters(_conversationList[0].Username, 100, "bad token", 0); + var actual = await _conversationStore.GetConversations(parameters); + }); + } + + [Fact] + public async Task GetConversationMessages_WithUnixTime() + { + foreach (var conversation in _conversationList) + { + await _conversationStore.CreateUserConversation(conversation); + } + + var parameters = new GetConversationsParameters(_conversationList[0].Username, 100, "", 1000); + var actual = await _conversationStore.GetConversations(parameters); + + Assert.Equivalent(_conversationList[0], actual.Conversations[0]); + Assert.Equivalent(_conversationList[1], actual.Conversations[1]); + } +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web.IntegrationTests/SQLMessageStoreTests.cs b/ChatApplication/ChatApplication.Web.IntegrationTests/SQLMessageStoreTests.cs new file mode 100644 index 0000000..7fab49e --- /dev/null +++ b/ChatApplication/ChatApplication.Web.IntegrationTests/SQLMessageStoreTests.cs @@ -0,0 +1,203 @@ +using ChatApplication.Configuration; +using ChatApplication.Exceptions; +using ChatApplication.Storage; +using ChatApplication.Storage.SQL; +using ChatApplication.Web.Dtos; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Azure.Cosmos; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace ChatApplication.Web.IntegrationTests; + +public class SQLMessageStoreTests: IClassFixture>, IAsyncLifetime +{ + private readonly IMessageStore _messageStore; + private readonly IConversationStore _conversationStore; + private readonly IProfileStore _profileStore; + private readonly string _conversationId; + private readonly string _profile1_username; + private readonly string _profile2_username; + private readonly Profile _profile1; + private readonly Profile _profile2; + private readonly UserConversation _userConversation1; + private readonly UserConversation _userConversation2; + private readonly Message _message1; + private readonly Message _message2; + private readonly Message _message3; + private readonly ConversationMessage _conversationMessage1; + private readonly ConversationMessage _conversationMessage2; + private readonly ConversationMessage _conversationMessage3; + private readonly List _conversationMessageList; + private readonly List _messageList; + + public async Task InitializeAsync() + { + await _profileStore.AddProfile(_profile1); + await _profileStore.AddProfile(_profile2); + await _conversationStore.CreateUserConversation(_userConversation1); + await _conversationStore.CreateUserConversation(_userConversation2); + } + + public async Task DisposeAsync() + { + await _profileStore.DeleteProfile(_profile1.Username); + await _profileStore.DeleteProfile(_profile2.Username); + } + + + public SQLMessageStoreTests(WebApplicationFactory factory) + { + + _conversationId = Guid.NewGuid().ToString(); + _profile1_username = Guid.NewGuid().ToString(); + _profile2_username = Guid.NewGuid().ToString(); + + _message1 = new Message(Guid.NewGuid().ToString(), _profile1_username, _conversationId, "hello", 1002); + _message2 = new Message(Guid.NewGuid().ToString(), _profile1_username, _conversationId, "hello", 1001); + _message3 = new Message(Guid.NewGuid().ToString(), _profile1_username, _conversationId, "hello", 1000); + + _profile1 = new Profile(_profile1_username, "ronald", "ronald", "ronald"); + _profile2 = new Profile(_profile2_username, "jad", "jad", "jad"); + + _conversationMessage1 = new ConversationMessage(_profile1_username, "hello", 1002); + _conversationMessage2 = new ConversationMessage(_profile1_username, "hello", 1001); + _conversationMessage3 = new ConversationMessage(_profile1_username, "hello", 1000); + + _messageList = new List() { _message1, _message2, _message3 }; + _conversationMessageList = new List() { _conversationMessage1, _conversationMessage2, _conversationMessage3 }; + + _userConversation1 = new UserConversation(_conversationId, new List() { _profile2 }, 0, _profile1.Username); + _userConversation2 = new UserConversation(_conversationId, new List() { _profile1 }, 0, _profile2.Username); + + var services = factory.Services; + var sqlSettings = services.GetRequiredService>(); + _conversationStore = new SQLConversationStore(sqlSettings); + _profileStore = new SQLProfileStore(sqlSettings); + _messageStore = new SQLMessageStore(sqlSettings); + } + + [Fact] + public async Task AddMessage_Success() + { + + await _conversationStore.CreateUserConversation(new UserConversation(_conversationId, new List() { _profile2 }, 0, _profile1.Username)); + await _messageStore.AddMessage(_messageList[0]); + + var parameters = new GetMessagesParameters(_messageList[0].ConversationId, 100, "", 0); + var actual = await _messageStore.GetMessages(parameters); + + Assert.Equal(_conversationMessageList[0], actual.Messages[0]); + } + + [Fact] + public async Task AddMessage_MessageAlreadyExists() + { + await _messageStore.AddMessage(_messageList[0]); + + await Assert.ThrowsAsync(async () => + { + await _messageStore.AddMessage(_messageList[0]); + }); + } + + [Fact] + public async Task DeleteMessage_Success() + { + await _messageStore.AddMessage(_messageList[0]); + await _messageStore.DeleteMessage(_messageList[0]); + + var parameters = new GetMessagesParameters(_messageList[0].ConversationId, 100, "", 0); + var actual = await _messageStore.GetMessages(parameters); + + Assert.Empty(actual.Messages); + } + + + [Fact] + public async Task GetConversationMessages_Success() + { + var expected = new List(); + foreach (var message in _messageList) + { + await _messageStore.AddMessage(message); + expected.Add(new ConversationMessage(message.SenderUsername, message.Text, + message.CreatedUnixTime)); + } + + var parameters = new GetMessagesParameters(_messageList[0].ConversationId, 100, "", 0); + var actual = await _messageStore.GetMessages(parameters); + + Assert.Equal(expected, actual.Messages); + } + + + [Fact] + public async Task GetConversationMessages_WithContinuationToken() + { + foreach (var message in _messageList) + { + await _messageStore.AddMessage(message); + } + var parametersFirstCall = new GetMessagesParameters(_messageList[0].ConversationId, 2, "", 0); + var actual = await _messageStore.GetMessages(parametersFirstCall); + + Assert.Equal(_conversationMessageList[0], actual.Messages[0]); + Assert.Equal(_conversationMessageList[1], actual.Messages[1]); + + var parametersSecondCall = new GetMessagesParameters(_messageList[0].ConversationId, 2, actual.ContinuationToken, 0); + var actual2 = await _messageStore.GetMessages(parametersSecondCall); + + Assert.Equal(_conversationMessageList[2], actual2.Messages[0]); + } + + + [Theory] + [InlineData(0, 1)] + [InlineData(-1, 1)] + [InlineData(150, 3)] + [InlineData(null, 1)] + public async Task GetConversationMessages_WithBadLimit(int limit, int actualCount) + { + foreach (var message in _messageList) + { + await _messageStore.AddMessage(message); + } + + var parameters = new GetMessagesParameters(_messageList[0].ConversationId, limit, "", 0); + var actual = await _messageStore.GetMessages(parameters); + + Assert.Equal(actualCount, actual.Messages.Count); + } + + [Fact] + public async Task GetConversationMessages_WithBadContinuationToken() + { + foreach (var message in _messageList) + { + await _messageStore.AddMessage(message); + } + + await Assert.ThrowsAsync(async () => + { + var parameters = new GetMessagesParameters(_messageList[0].ConversationId, 100, "bad token", 0); + var actual = await _messageStore.GetMessages(parameters); + }); + } + + [Fact] + public async Task GetConversationMessages_WithUnixTime() + { + foreach (var message in _messageList) + { + await _messageStore.AddMessage(message); + } + + var parameters = new GetMessagesParameters(_messageList[0].ConversationId, 100, "", 1000); + var actual = await _messageStore.GetMessages(parameters); + + Assert.Equal(_conversationMessageList[0], actual.Messages[0]); + Assert.Equal(_conversationMessageList[1], actual.Messages[1]); + } +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web.IntegrationTests/SQLProfileStoreTests.cs b/ChatApplication/ChatApplication.Web.IntegrationTests/SQLProfileStoreTests.cs new file mode 100644 index 0000000..e8f3911 --- /dev/null +++ b/ChatApplication/ChatApplication.Web.IntegrationTests/SQLProfileStoreTests.cs @@ -0,0 +1,129 @@ +using ChatApplication.Configuration; +using ChatApplication.Exceptions; +using ChatApplication.Storage; +using ChatApplication.Storage.SQL; +using ChatApplication.Web.Dtos; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Azure.Cosmos; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace ChatApplication.Web.IntegrationTests; + +public class SQLProfileStoreTests:IClassFixture>, IAsyncLifetime +{ + + private readonly IProfileStore _store; + private readonly Profile _profile = new( + Username: Guid.NewGuid().ToString(), + FirstName: "Foo", + LastName: "Bar", + ProfilePictureId: "123" + ); + + public SQLProfileStoreTests(WebApplicationFactory factory) + { + var services = factory.Services; + var sqlSettings = services.GetRequiredService>(); + _store = new SQLProfileStore(sqlSettings); + } + + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + await _store.DeleteProfile(_profile.Username); + } + + + [Fact] + + public async Task AddProfile_Success() + { + await _store.AddProfile(_profile); + Assert.Equal(_profile, await _store.GetProfile(_profile.Username)); + } + + [Fact] + public async Task GetProfile_NotFound() + { + await Assert.ThrowsAsync(async () => await _store.GetProfile(_profile.Username + "1")); + + } + + [Fact] + + public async Task GetProfile_EmptyProfile() + { + await Assert.ThrowsAsync(async () => + { + await _store.GetProfile(""); + }); + } + + + [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")] + public async Task AddProfile_InvalidArgs(string username, string firstName, string lastName, + string profilePictureId) + { + await Assert.ThrowsAsync(async () => + { + await _store.AddProfile(new Profile(username, firstName, lastName, profilePictureId)); + + }); + + } + + [Fact] + + public async Task AddProfile_NoImage() + { + var profile = new Profile(Guid.NewGuid().ToString(), "Foo", "Bar"); + await _store.AddProfile(profile); + var returnedProfile = await _store.GetProfile(profile.Username); + await _store.DeleteProfile(profile.Username); + Assert.Equal(profile, returnedProfile); + + } + + [Fact] + + public async Task DeleteProfile_Success() + { + await _store.AddProfile(_profile); + await _store.DeleteProfile(_profile.Username); + await Assert.ThrowsAsync(async()=> await _store.GetProfile(_profile.Username)); + } + + [Fact] + + public async Task DeleteProfile_EmptyProfile() + { + await _store.DeleteProfile(""); + } + + + [Fact] + + public async Task AddProfile_Conflict() + { + await _store.AddProfile(_profile); + await Assert.ThrowsAsync(async () => + { + await _store.AddProfile(_profile); + }); + } +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web.IntegrationTests/Usings.cs b/ChatApplication/ChatApplication.Web.IntegrationTests/Usings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/ChatApplication/ChatApplication.Web.IntegrationTests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web.Tests/ChatApplication.Web.Tests.csproj b/ChatApplication/ChatApplication.Web.Tests/ChatApplication.Web.Tests.csproj new file mode 100644 index 0000000..a328e90 --- /dev/null +++ b/ChatApplication/ChatApplication.Web.Tests/ChatApplication.Web.Tests.csproj @@ -0,0 +1,37 @@ + + + + net7.0 + enable + enable + + false + + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/ChatApplication/ChatApplication.Web.Tests/Controllers/ConversationControllerTests.cs b/ChatApplication/ChatApplication.Web.Tests/Controllers/ConversationControllerTests.cs new file mode 100644 index 0000000..998273e --- /dev/null +++ b/ChatApplication/ChatApplication.Web.Tests/Controllers/ConversationControllerTests.cs @@ -0,0 +1,371 @@ +using System.Net; +using System.Text; +using ChatApplication.Exceptions; +using ChatApplication.Exceptions.ConversationParticipantsExceptions; +using ChatApplication.Exceptions.StorageExceptions; +using ChatApplication.Services; +using ChatApplication.Web.Dtos; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Newtonsoft.Json; + +namespace ChatApplication.Web.Tests.Controllers; + +public class ConversationControllerTests : IClassFixture> +{ + private readonly Mock _conversationServiceMock; + private readonly HttpClient _httpClient; + + public ConversationControllerTests(WebApplicationFactory factory) + { + _conversationServiceMock = new Mock(); + _httpClient = factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => { services.AddSingleton(_conversationServiceMock.Object); }); + }).CreateClient(); + } + + + [Fact] + public async Task AddMessage_Success_201() + { + var messageRequest = new SendMessageRequest("1234", "ronald", "hey bro wanna hit the gym"); + const string conversationId = "456"; + var jsonContent = new StringContent(JsonConvert.SerializeObject(messageRequest), Encoding.Default, "application/json"); + var response = await _httpClient.PostAsync($"/api/Conversations/{conversationId}/messages", jsonContent); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.Equal($"http://localhost/api/Conversations/{conversationId}/messages", response.Headers.GetValues("Location").First()); + } + + + [Theory] + [InlineData("", "ronald", "hey bro wanna hit the gym", "456")] + [InlineData("1234", "", "hey bro wanna hit the gym", "456")] + [InlineData("1234", "ronald", "", "456")] + [InlineData(" ", "ronald", "hey bro wanna hit the gym", "456")] + [InlineData("1234", " ", "hey bro wanna hit the gym", "456")] + [InlineData("1234", "ronald", " ", "456")] + [InlineData("1234", "ronald", "hey bro wanna hit the gym", " ")] + [InlineData(null, "ronald", "hey bro wanna hit the gym", "456")] + [InlineData("1234", null, "hey bro wanna hit the gym", "456")] + [InlineData("1234", "ronald", null, "456")] + + public async Task AddMessage_InvalidArguments_400(string messageId, string senderUsername, string messageContent, + string conversationId) + { + var messageRequest = new SendMessageRequest(messageId, senderUsername, messageContent); + var message = new Message(messageId, senderUsername, conversationId, messageContent,1000); + var jsonContent = new StringContent(JsonConvert.SerializeObject(messageRequest), Encoding.Default, "application/json"); + var response = await _httpClient.PostAsync($"/api/Conversations/{conversationId}/messages", jsonContent); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + _conversationServiceMock.Verify(mock => mock.AddMessage(message), Times.Never); + } + + [Fact] + + public async Task AddMessage_ConversationNotFound_404() + { + var messageRequest = new SendMessageRequest("1234", "ronald", "hey bro wanna hit the gym"); + const string conversationId = "456"; + + _conversationServiceMock.Setup(x => x.EnqueueAddMessage( + It.Is(m => + m.MessageId == messageRequest.Id && + m.SenderUsername == messageRequest.SenderUsername && + m.Text == messageRequest.Text && + m.ConversationId == conversationId + ))).ThrowsAsync(new ConversationNotFoundException(conversationId)); + + var jsonContent = new StringContent(JsonConvert.SerializeObject(messageRequest), Encoding.Default, "application/json"); + var response = await _httpClient.PostAsync($"/api/Conversations/{conversationId}/messages", jsonContent); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + + [Fact] + public async Task AddMessage_MessageAlreadyExists_409() + { + var messageRequest = new SendMessageRequest("1234", "ronald", "hey bro wanna hit the gym"); + const string conversationId = "456"; + var message = new Message(messageRequest.Id, messageRequest.SenderUsername, conversationId, messageRequest.Text, 1000); + + _conversationServiceMock.Setup(x => x.EnqueueAddMessage( + It.Is(m => + m.MessageId == messageRequest.Id && + m.SenderUsername == messageRequest.SenderUsername && + m.Text == messageRequest.Text && + m.ConversationId == conversationId + ))).ThrowsAsync(new MessageAlreadyExistsException(message.MessageId)); + + var jsonContent = new StringContent(JsonConvert.SerializeObject(messageRequest), Encoding.Default, "application/json"); + var response = await _httpClient.PostAsync($"/api/Conversations/{conversationId}/messages", jsonContent); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + [Fact] + + public async Task AddMessage_StorageUnavailable_503() + { + var messageRequest = new SendMessageRequest("1234", "ronald", "hey bro wanna hit the gym"); + const string conversationId = "456"; + + _conversationServiceMock.Setup(x => x.EnqueueAddMessage( + It.Is(m => + m.MessageId == messageRequest.Id && + m.SenderUsername == messageRequest.SenderUsername && + m.Text == messageRequest.Text && + m.ConversationId == conversationId + ))).ThrowsAsync(new StorageUnavailableException("database is down")); + + var jsonContent = new StringContent(JsonConvert.SerializeObject(messageRequest), Encoding.Default, "application/json"); + var response = await _httpClient.PostAsync($"/api/Conversations/{conversationId}/messages", jsonContent); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + } + + [Fact] + public async Task StartConversation_Success_201() + { + var messageRequest = new SendMessageRequest("12345", "Ronald", "Haha Bro farex"); + var participants = new List {"Ronald", "Farex"}; + var conversationRequest = new StartConversationRequest(participants, messageRequest); + + _conversationServiceMock.Setup(x => x.EnqueueStartConversation( + It.Is(r => + r.participants.SequenceEqual(participants) && r.messageContent == messageRequest.Text && + r.messageId == messageRequest.Id && r.senderUsername == messageRequest.SenderUsername + ))).ReturnsAsync("_Ronald_Farex"); + + var jsonContent = new StringContent(JsonConvert.SerializeObject(conversationRequest), Encoding.Default, "application/json"); + var response = await _httpClient.PostAsync("/api/Conversations", jsonContent); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.Equal("http://localhost/api/Conversations?username=Ronald", response.Headers.GetValues("Location").First()); + + var responseString = await response.Content.ReadAsStringAsync(); + var answer = JsonConvert.DeserializeObject(responseString); + + Assert.Equal("_Ronald_Farex", answer.Id); + + _conversationServiceMock.Verify(mock => mock.EnqueueStartConversation( + It.Is(r => + r.participants.SequenceEqual(participants) && r.messageContent == messageRequest.Text && + r.messageId == messageRequest.Id && r.senderUsername == messageRequest.SenderUsername + )), Times.Once); + } + + [Fact] + + public async Task StartConversation_LessThanTwoParticipants_400() + { + var messageRequest = new SendMessageRequest("12345", "Ronald", "Haha Bro farex"); + var participants = new List {"Ronald"}; + var conversationRequest = new StartConversationRequest(participants, messageRequest); + + var jsonContent = new StringContent(JsonConvert.SerializeObject(conversationRequest), Encoding.Default, "application/json"); + var response = await _httpClient.PostAsync("/api/Conversations", jsonContent); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task StartConversation_SenderUsernameNotInParticipants_400() + { + var messageRequest = new SendMessageRequest("12345", "Ronald", "Haha Bro farex"); + var participants = new List { "Farex", "Messi" }; + var conversationRequest = new StartConversationRequest(participants, messageRequest); + + _conversationServiceMock.Setup(x => x.EnqueueStartConversation( + It.Is(r => + r.participants.SequenceEqual(participants) && r.messageContent == messageRequest.Text && + r.messageId == messageRequest.Id && r.senderUsername == messageRequest.SenderUsername))) + .ThrowsAsync(new SenderNotFoundException(messageRequest.SenderUsername)); + + var jsonContent = new StringContent(JsonConvert.SerializeObject(conversationRequest), + Encoding.Default, "application/json"); + + var response = await _httpClient.PostAsync("api/Conversations", jsonContent); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task StartConversation_ProfileNotFound_404() + { + var messageRequest = new SendMessageRequest("12345", "Ronald", "Haha Bro farex"); + var participants = new List { "Ronald", "Farex" }; + var conversationRequest = new StartConversationRequest(participants, messageRequest); + + _conversationServiceMock.Setup(x => x.EnqueueStartConversation( + It.Is(r => + r.participants.SequenceEqual(participants) && r.messageContent == messageRequest.Text && + r.messageId == messageRequest.Id && r.senderUsername == messageRequest.SenderUsername))) + .ThrowsAsync(new ProfileNotFoundException("Profile does not exist")); + + var jsonContent = new StringContent(JsonConvert.SerializeObject(conversationRequest), + Encoding.Default, "application/json"); + + var response = await _httpClient.PostAsync("/api/Conversations", jsonContent); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task StartConversation_MessageAlreadyExists_409() + { + var messageRequest = new SendMessageRequest("12345", "Ronald", "Haha Bro farex"); + var participants = new List { "Ronald", "Farex" }; + var conversationRequest = new StartConversationRequest(participants, messageRequest); + + _conversationServiceMock.Setup(x => x.EnqueueStartConversation( + It.Is(r => + r.participants.SequenceEqual(participants) && r.messageContent == messageRequest.Text && + r.messageId == messageRequest.Id && r.senderUsername == messageRequest.SenderUsername))) + .ThrowsAsync(new MessageAlreadyExistsException("Message already exists")); + + var jsonContent = new StringContent(JsonConvert.SerializeObject(conversationRequest), + Encoding.Default, "application/json"); + + var response = await _httpClient.PostAsync("/api/Conversations", jsonContent); + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + [Fact] + + public async Task StartConversation_DuplicateParticipant_400() + { + var messageRequest = new SendMessageRequest("12345", "Ronald", "Haha Bro farex"); + var participants = new List { "Ronald", "Farex", "Ronald" }; + var conversationRequest = new StartConversationRequest(participants, messageRequest); + + _conversationServiceMock.Setup(x => x.EnqueueStartConversation( + It.Is(r => + r.participants.SequenceEqual(participants) && r.messageContent == messageRequest.Text && + r.messageId == messageRequest.Id && r.senderUsername == messageRequest.SenderUsername))) + .ThrowsAsync(new DuplicateParticipantException("Participant is duplicated")); + + var jsonContent = new StringContent(JsonConvert.SerializeObject(conversationRequest), + Encoding.Default, "application/json"); + + var response = await _httpClient.PostAsync("/api/Conversations", jsonContent); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + + public async Task StartConversation_StorageNotAvailable_503() + { + var messageRequest = new SendMessageRequest("12345", "Ronald", "Haha Bro farex"); + var participants = new List { "Ronald", "Farex" }; + var conversationRequest = new StartConversationRequest(participants, messageRequest); + + _conversationServiceMock.Setup(x => x.EnqueueStartConversation( + It.Is(r => + r.participants.SequenceEqual(participants) && r.messageContent == messageRequest.Text && + r.messageId == messageRequest.Id && r.senderUsername == messageRequest.SenderUsername))) + .ThrowsAsync(new StorageUnavailableException("database is down")); + + var jsonContent = new StringContent(JsonConvert.SerializeObject(conversationRequest), Encoding.Default, "application/json"); + var response = await _httpClient.PostAsync("/api/Conversations", jsonContent); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + + } + + [Fact] + public async Task GetConversationMessages_Success_200() + { + const string conversationId = "_Farex_Ronald"; + const string nextContinuationToken = "frfr"; + var messages = new List + { + new("12345", "Farex", 0), + new("12346", "Ronald", 1) + }; + var parameters = new GetMessagesParameters(conversationId, 50, "", 0); + + _conversationServiceMock + .Setup(x => x.GetMessages(parameters)) + .ReturnsAsync(new GetMessagesResult(messages, nextContinuationToken)); + + const string expectedNextUri = $"/api/Conversations/{conversationId}/messages?limit=50&continuationToken={nextContinuationToken}&lastSeenMessageTime=0"; + const string uri = $"/api/conversations/{conversationId}/messages/"; + var response = await _httpClient.GetAsync(uri); + var responseString = await response.Content.ReadAsStringAsync(); + var getConversationMessagesResponseReceived = JsonConvert.DeserializeObject(responseString); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(messages, getConversationMessagesResponseReceived.Messages); + Assert.Equal(expectedNextUri, getConversationMessagesResponseReceived.NextUri); + } + + [Fact] + + public async Task GetAllMessages_StorageUnavailable_503() + { + const string conversationId = "_Farex_Ronald"; + var parameters = new GetMessagesParameters(conversationId, 50, "", 0); + + _conversationServiceMock + .Setup(x => x.GetMessages(parameters)) + .ThrowsAsync(new StorageUnavailableException("database is down")); + + const string uri = $"/api/conversations/{conversationId}/messages/"; + var response = await _httpClient.GetAsync(uri); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + } + + [Fact] + public async Task GetAllConversations_Success_200() + { + const string username = "jad"; + const string nextContinuationToken = "frfr"; + var recipients1 = new List{new("karim", "karim", "haddad", "1234")}; + var recipients2 = new List{new("ronald", "ronald", "haddad", "1234")}; + var conversation1 = new UserConversation("_jad_ronald", recipients1, 1000, "jad"); + var conversation2 = new UserConversation("_jad_karim", recipients2, 1001, "jad"); + var conversations = new List { conversation1, conversation2 }; + var conversationsMetadata = conversations.Select(conversation => new ConversationMetaData(conversation.ConversationId, conversation.LastMessageTime, conversation.Recipients[0])).ToList(); + + var parameters = new GetConversationsParameters(username, 50, "", 0); + _conversationServiceMock + .Setup(x => x.GetConversations(parameters)) + .ReturnsAsync(new GetConversationsResult(conversations, nextContinuationToken)); + + const string expectedNextUri = $"/api/Conversations?username={username}&limit=50&continuationToken={nextContinuationToken}&lastSeenConversationTime=0"; + const string uri = $"/api/conversations?username={username}"; + var response = await _httpClient.GetAsync(uri); + var responseString = await response.Content.ReadAsStringAsync(); + var getAllConversationsResponseReceived = + JsonConvert.DeserializeObject(responseString); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equivalent(conversationsMetadata, getAllConversationsResponseReceived.Conversations); + Assert.Equal(expectedNextUri, getAllConversationsResponseReceived.NextUri); + } + + [Fact] + + public async Task GetAllConversations_StorageUnavailable_503() + { + + const string username = "Ronald"; + + var parameters = new GetConversationsParameters(username, 50, "", 0); + _conversationServiceMock + .Setup(x => x.GetConversations(parameters)) + .ThrowsAsync(new StorageUnavailableException("database is down")); + + const string uri = $"/api/conversations?username={username}"; + var response = await _httpClient.GetAsync(uri); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + } + +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web.Tests/Controllers/ImagesControllerTests.cs b/ChatApplication/ChatApplication.Web.Tests/Controllers/ImagesControllerTests.cs new file mode 100644 index 0000000..3a24f6e --- /dev/null +++ b/ChatApplication/ChatApplication.Web.Tests/Controllers/ImagesControllerTests.cs @@ -0,0 +1,188 @@ +using System.Net; +using ChatApplication.Exceptions; +using ChatApplication.Exceptions.StorageExceptions; +using ChatApplication.Services; +using ChatApplication.Utils; +using ChatApplication.Web.Dtos; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using MediaTypeHeaderValue = System.Net.Http.Headers.MediaTypeHeaderValue; + +namespace ChatApplication.Web.Tests.Controllers; + +public class ImagesControllerTests : IClassFixture> +{ + private readonly Mock _imageServiceMock = new(); + private readonly HttpClient _httpClient; + + public ImagesControllerTests(WebApplicationFactory factory) + { + + _httpClient = factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => { services.AddSingleton(_imageServiceMock.Object); }); + }).CreateClient(); + } + + + + + [Fact] + + public async Task GetImage_Success_200() + { + var image = new byte[] {1, 2, 3, 4, 5}; + var imageId = "123"; + + Image expectedImage = new(image, "image/jpeg"); + _imageServiceMock.Setup(m => m.GetImage(imageId)).ReturnsAsync(expectedImage); + + var response = await _httpClient.GetAsync($"/api/Images/{imageId}"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var contentActual = await response.Content.ReadAsByteArrayAsync(); + var contentTypeActual = response.Content.Headers.ContentType?.ToString(); + + Assert.Equal(expectedImage.ImageData, contentActual); + Assert.Equal(expectedImage.ContentType, contentTypeActual); + + } + + [Fact] + public async Task GetImage_NotFound_404() + { + _imageServiceMock.Setup(m => m.GetImage("123")).ThrowsAsync(new ImageNotFoundException("Image not Found")); + var response = await _httpClient.GetAsync($"/api/Images/123"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + + public async Task GetImage_InvalidId_400() + { + _imageServiceMock.Setup(m => m.GetImage("123")).ThrowsAsync(new ArgumentException()); + var response = await _httpClient.GetAsync($"/api/Images/123"); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + } + + [Fact] + + public async Task GetImage_StorageUnavailable_503() + { + _imageServiceMock.Setup(m => m.GetImage("123")) + .ThrowsAsync(new StorageUnavailableException("database is down")); + var response = await _httpClient.GetAsync($"/api/Images/123"); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + } + + [Fact] + public async Task UploadImage_Success_201() + { + var image = new byte[] { 1, 2, 3, 4, 5 }; + const string imageId = "123"; + const string fileName = "test.jpeg"; + var streamFile = new MemoryStream(image); + IFormFile file = new FormFile(streamFile, 0, streamFile.Length, "id_from_form", fileName) + { + Headers = new HeaderDictionary(), + ContentType = "image/jpeg" + }; + var uploadRequest = new UploadImageRequest(file); + _imageServiceMock.Setup(m => m.AddImage(It.IsAny(), "image/jpeg")).ReturnsAsync(imageId); + + using var formData = new MultipartFormDataContent(); + var requestContent = new StreamContent(uploadRequest.File.OpenReadStream()); + requestContent.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg"); + formData.Add(requestContent, "File", uploadRequest.File.FileName); + + var response = await _httpClient.PostAsync("/api/Images", formData); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + _imageServiceMock.Verify(mock => mock.AddImage(It.IsAny(), "image/jpeg"), Times.Once); + } + + [Fact] + + public async Task UploadImage_PDFContentType_201() + { + var image = new byte[] { 1, 2, 3, 4, 5 }; + const string fileName = "test.pdf"; + const string imageId = "1"; + var streamFile = new MemoryStream(image); + IFormFile file = new FormFile(streamFile, 0, streamFile.Length, "id_from_form", fileName) + { + Headers = new HeaderDictionary(), + ContentType = "image/pdf" + }; + var uploadRequest = new UploadImageRequest(file); + _imageServiceMock.Setup(m => m.AddImage(It.IsAny(), It.IsAny())).ReturnsAsync(imageId); + + using var formData = new MultipartFormDataContent(); + formData.Add(new StreamContent(uploadRequest.File.OpenReadStream()), "File", uploadRequest.File.FileName); + + var response = await _httpClient.PostAsync("/api/images", formData); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + _imageServiceMock.Verify(mock => mock.AddImage(It.IsAny(), It.IsAny()), Times.Once); + } + + + [Fact] + + public async Task UploadImage_EmptyFile_200() + { + var image = Array.Empty(); + const string fileName = "test.jpg"; + var streamFile = new MemoryStream(image); + IFormFile file = new FormFile(streamFile, 0, streamFile.Length, "id_from_form", fileName) + { + Headers = new HeaderDictionary(), + ContentType = "image/jpg" + }; + var uploadRequest = new UploadImageRequest(file); + _imageServiceMock.Setup(m => m.AddImage(It.IsAny(), It.IsAny())); + + using var formData = new MultipartFormDataContent(); + formData.Add(new StreamContent(uploadRequest.File.OpenReadStream()), "File", uploadRequest.File.FileName); + + var response = await _httpClient.PostAsync("/api/Images", formData); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + _imageServiceMock.Verify(mock => mock.AddImage(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + + public async Task UploadImage_StorageUnavailable_503() + { + var image = new byte[] { 1, 2, 3, 4, 5 }; + const string fileName = "test.pdf"; + const string imageId = "1"; + var streamFile = new MemoryStream(image); + IFormFile file = new FormFile(streamFile, 0, streamFile.Length, "id_from_form", fileName) + { + Headers = new HeaderDictionary(), + ContentType = "image/pdf" + }; + var uploadRequest = new UploadImageRequest(file); + _imageServiceMock.Setup(m => m.AddImage(It.IsAny(), It.IsAny())) + .ThrowsAsync(new StorageUnavailableException("database is down")); + + using var formData = new MultipartFormDataContent(); + formData.Add(new StreamContent(uploadRequest.File.OpenReadStream()), "File", uploadRequest.File.FileName); + + var response = await _httpClient.PostAsync("/api/images", formData); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + } + +} + diff --git a/ChatApplication/ChatApplication.Web.Tests/Controllers/ProfileControllerTests.cs b/ChatApplication/ChatApplication.Web.Tests/Controllers/ProfileControllerTests.cs new file mode 100644 index 0000000..f32a9d5 --- /dev/null +++ b/ChatApplication/ChatApplication.Web.Tests/Controllers/ProfileControllerTests.cs @@ -0,0 +1,147 @@ +using System.Net; +using System.Text; +using ChatApplication.Exceptions; +using ChatApplication.Exceptions.StorageExceptions; +using ChatApplication.Services; +using ChatApplication.Web.Dtos; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Newtonsoft.Json; +namespace ChatApplication.Web.Tests.Controllers; + + + + +public class ProfileControllerTests: IClassFixture> +{ + private readonly Mock _profileServiceMock = new(); + private readonly HttpClient _httpClient; + + public ProfileControllerTests(WebApplicationFactory factory) + { + _httpClient = factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => { services.AddSingleton(_profileServiceMock.Object); }); + }).CreateClient(); + } + + [Fact] + public async Task GetProfile_Success_200() + { + var profile = new Profile("foobar", "Foo", "Bar", "12345"); + _profileServiceMock.Setup(m => m.GetProfile(profile.Username)) + .ReturnsAsync(profile); + + var response = await _httpClient.GetAsync($"/api/Profile/{profile.Username}"); + var json = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(profile, JsonConvert.DeserializeObject(json)); + } + + [Fact] + public async Task GetProfile_NotFound_404() + { + _profileServiceMock.Setup(m => m.GetProfile("foobar")) + .ThrowsAsync(new ProfileNotFoundException("Profile not found")); + + var response = await _httpClient.GetAsync($"/api/Profile/foobar"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + + public async Task GetProfile_StorageUnavailable_503() + { + _profileServiceMock.Setup(m => m.GetProfile("foobar")) + .ThrowsAsync(new StorageUnavailableException("database is down")); + + var response = await _httpClient.GetAsync($"/api/Profile/foobar"); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + } + + [Fact] + public async Task AddProfile_Success_201() + { + var profile = new Profile("foobar", "Foo", "Bar", "12345"); + var response = await _httpClient.PostAsync("/api/Profile", + new StringContent(JsonConvert.SerializeObject(profile), Encoding.Default, "application/json")); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.Equal("http://localhost/api/Profile/foobar", response.Headers.GetValues("Location").First()); + + _profileServiceMock.Verify(mock => mock.AddProfile(profile), Times.Once); + } + + [Fact] + public async Task AddProfile_Conflict_409() + { + var profile = new Profile("foobar", "Foo", "Bar", "12345"); + _profileServiceMock.Setup(m => m.AddProfile(profile)) + .ThrowsAsync(new ProfileAlreadyExistsException("Profile already exists")); + + var response = await _httpClient.PostAsync("/api/Profile", + new StringContent(JsonConvert.SerializeObject(profile), Encoding.Default, "application/json")); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + + + + + [Theory] + [InlineData(null, "Foo", "Bar", "12345")] + [InlineData("", "Foo", "Bar", "12345")] + [InlineData(" ", "Foo", "Bar", "12345")] + [InlineData("foobar", null, "Bar", "12345")] + [InlineData("foobar", "", "Bar", "12345")] + [InlineData("foobar", " ", "Bar", "12345")] + [InlineData("foobar", "Foo", "", "12345")] + [InlineData("foobar", "Foo", null, "12345")] + [InlineData("foobar", "Foo", " ", "12345")] + public async Task AddProfile_InvalidArgs_400(string username, string firstName, string lastName, string profilePictureId ) + { + var profile = new Profile(username, firstName, lastName, profilePictureId); + var response = await _httpClient.PostAsync("/api/Profile", + new StringContent(JsonConvert.SerializeObject(profile), Encoding.Default, "application/json")); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + _profileServiceMock.Verify(mock => mock.AddProfile(profile), Times.Never); + } + + + [Fact] + public async Task AddProfile_InvalidImage_400() + { + var profile = new Profile("foobar", "Foo", "Bar", "12345"); + _profileServiceMock.Setup(m=> m.AddProfile(profile)) + .ThrowsAsync(new ImageNotFoundException("Image not found")); + + var response = await _httpClient.PostAsync("/api/Profile", + new StringContent(JsonConvert.SerializeObject(profile), Encoding.Default, "application/json")); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + + public async Task AddProfile_StorageUnavailable_503() + { + var profile = new Profile("foobar", "Foo", "Bar", "12345"); + _profileServiceMock.Setup(m => m.AddProfile(profile)) + .ThrowsAsync(new StorageUnavailableException("database is down")); + + var response = await _httpClient.PostAsync("/api/Profile", + new StringContent(JsonConvert.SerializeObject(profile), Encoding.Default, "application/json")); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + } + + +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web.Tests/Services/ConversationServiceTests.cs b/ChatApplication/ChatApplication.Web.Tests/Services/ConversationServiceTests.cs new file mode 100644 index 0000000..c1ad457 --- /dev/null +++ b/ChatApplication/ChatApplication.Web.Tests/Services/ConversationServiceTests.cs @@ -0,0 +1,490 @@ +using ChatApplication.Exceptions; +using ChatApplication.Exceptions.ConversationParticipantsExceptions; +using ChatApplication.ServiceBus.Interfaces; +using ChatApplication.Services; +using ChatApplication.Storage; +using ChatApplication.Web.Dtos; +using Moq; + +namespace ChatApplication.Web.Tests.Services; + +public class ConversationServiceTests +{ + private readonly Mock _messageStoreMock = new(); + private readonly Mock _conversationStoreMock = new(); + private readonly Mock _profileStoreMock = new(); + private readonly Mock _addMessageServiceBusPublisherMock = new(); + private readonly Mock _startConversationServiceBusPublisherMock = new(); + private readonly ConversationService _conversationService; + public ConversationServiceTests() + { + _conversationService = new ConversationService(_messageStoreMock.Object, _conversationStoreMock.Object, + _profileStoreMock.Object, _addMessageServiceBusPublisherMock.Object, _startConversationServiceBusPublisherMock.Object); + } + + [Fact] + + public async Task AddMessage_Success() + { + var message = new Message("123", "jad", "_jad_rizz", "bro got W rizz", 1000); + var recipientProfile = new Profile("rizz", "ok", "ok", "ok"); + var senderConversation = new UserConversation("_jad_rizz", new List{recipientProfile}, 1000, "jad"); + + _conversationStoreMock.Setup(m => m.GetUserConversation("jad", "_jad_rizz")).ReturnsAsync(senderConversation); + + await _conversationService.AddMessage(message); + + _conversationStoreMock.Verify(mock => mock.UpdateConversationLastMessageTime(senderConversation, message.CreatedUnixTime), Times.Once); + _messageStoreMock.Verify(mock => mock.AddMessage(message), Times.Once); + } + + + [Fact] + + public async Task AddMessage_ConversationNotFound() + { + var message = new Message("123", "jad", "_jad_rizz", "bro got W rizz", 1000); + _conversationStoreMock.Setup(m => m.GetUserConversation("jad", "_jad_rizz")).ThrowsAsync(new ConversationNotFoundException("Conversation not found")); + + await Assert.ThrowsAsync (async() => + { + await _conversationService.AddMessage(message); + }); + + _messageStoreMock.Verify(mock => mock.AddMessage(message), Times.Never); + } + + [Fact] + + public async Task AddMessage_MessageAlreadyExists() + { + var message = new Message("123", "jad", "1234", "bro got W rizz",1000); + var recipientProfile = new Profile("ronald", "ronald", "Haddad", "123456"); + var recipients = new List {recipientProfile}; + var conversation = new UserConversation("1234", recipients, 100000,"jad"); + + _conversationStoreMock.Setup(m => m.GetUserConversation("jad",message.ConversationId)).ReturnsAsync(conversation); + _messageStoreMock.Setup(m => m.AddMessage(message)).ThrowsAsync(new MessageAlreadyExistsException("Message already exists")); + + var exception = await Record.ExceptionAsync(async () => + await _conversationService.AddMessage(message)); + + Assert.Null(exception); + _messageStoreMock.Verify(mock => mock.AddMessage(message), Times.Once); + } + + + + [Fact] + + public async Task StartConversation_Success() + { + const string messageId = "1234"; + const string senderUsername = "Ronald"; + const string messageContent = "aha aha aha"; + const long createdTime = 100000; + const string expectedId = "_Jad_Ronald"; + var participants = new List {"Ronald", "Jad"}; + + _profileStoreMock.Setup(x => x.GetProfile("Jad")).ReturnsAsync(new Profile("Jad", "ok", "gym", "1234")); + _profileStoreMock.Setup(x => x.GetProfile("Ronald")).ReturnsAsync(new Profile("Ronald", "ok", "gym", "1234")); + + var startConversationParameters = new StartConversationParameters(messageId, senderUsername, messageContent, createdTime, participants); + var actualId = await _conversationService.StartConversation(startConversationParameters); + + Assert.Equal(expectedId, actualId); + } + + [Fact] + + public async Task StartConversation_ConversationAlreadyExists() + { + const string messageId = "1234"; + const string senderUsername = "Ronald"; + const string messageContent = "aha aha aha"; + const long createdTime = 100000; + var participants = new List {"Jad", "Ronald"}; + var expectedId = ""; + + foreach (var participant in participants) + { + expectedId += "_" + participant; + var profile = new Profile(participant, "ok", "gym", "1234"); + _profileStoreMock.Setup(x => x.GetProfile(participant)).ReturnsAsync(profile); + } + + _conversationStoreMock.Setup(x => x.CreateUserConversation( + It.Is(c => + c.ConversationId == expectedId + && c.LastMessageTime==createdTime + && c.Username == senderUsername + && c.Recipients.All(p => typeof(Profile) == p.GetType()) + && c.Recipients.Any(p => p.Username == "Jad") + ) + )).ThrowsAsync(new ConversationAlreadyExistsException("Conversation already exists")); + + var startConversationParameters = new StartConversationParameters(messageId, senderUsername, messageContent, createdTime, participants); + var exception = await Record.ExceptionAsync(async () => + await _conversationService.StartConversation(startConversationParameters)); + + Assert.Null(exception); + } + + [Fact] + + public async Task StartConversation_MessageAlreadyExists() + { + const string messageId = "1234"; + const string senderUsername = "Ronald"; + const string messageContent = "aha aha aha"; + const long createdTime = 100000; + const string conversationId = "_Jad_Ronald"; + var participants = new List {"Ronald", "Jad"}; + + foreach(var participant in participants) + { + var profile = new Profile(participant, "ok", "gym", "1234"); + _profileStoreMock.Setup(x => x.GetProfile(participant)).ReturnsAsync(profile); + } + + var message = new Message(messageId, senderUsername, conversationId, messageContent, createdTime); + _messageStoreMock.Setup(x => x.AddMessage(message)).ThrowsAsync(new MessageAlreadyExistsException("Message already exists")); + + var startConversationParameters = new StartConversationParameters(messageId, senderUsername, messageContent, createdTime, participants); + var exception = await Record.ExceptionAsync( + async () => await _conversationService.StartConversation(startConversationParameters)); + + Assert.Null(exception); + + } + + [Fact] + + public async Task StartConversation_SenderUsernameNotFound() + { + const string messageId = "123"; + const string messageContent = "south park vs family guy"; + const int createdTime = 10000; + var participants = new List {"Ronald", "Stewie"}; + var senderProfile = new Profile("Jad", "Jad", "Haddad", "12345"); + + _profileStoreMock.Setup(x => x.GetProfile("Ronald")).ReturnsAsync(senderProfile); + _profileStoreMock.Setup(x => x.GetProfile("Stewie")).ReturnsAsync(new Profile("Stewie", "Stewie", "Griffin", "12345")); + + var startConversationParameters = new StartConversationParameters(messageId, senderProfile.Username, messageContent, createdTime, participants); + var exception = await Record.ExceptionAsync(async()=> + await _conversationService.StartConversation(startConversationParameters)); + + Assert.Null(exception); + } + + [Fact] + + public async Task StartConversation_DuplicateParticipant() + { + const string messageId = "123"; + const string messageContent = "south park vs family guy"; + const int createdTime = 10000; + var participants = new List {"Ronald", "Stewie"}; + var senderProfile = new Profile("Ronald", "Jad", "Haddad", "12345"); + + _profileStoreMock.Setup(x => x.GetProfile("Ronald")).ReturnsAsync(senderProfile); + _profileStoreMock.Setup(x => x.GetProfile("Stewie")).ReturnsAsync(new Profile("Stewie", "Stewie", "Griffin", "12345")); + + var startConversationParameters = new StartConversationParameters(messageId, senderProfile.Username, messageContent, createdTime, participants); + var exception = await Record.ExceptionAsync(async () => + await _conversationService.StartConversation(startConversationParameters)); + + Assert.Null(exception); + } + + [Fact] + + public async Task GetConversationMessages_Success() + { + const string conversationId = "_karim_Ronald"; + const string continuationToken = "someWeirdString"; + const string nextContinuationToken = "anotherWeirdToken"; + var parameters = new GetMessagesParameters(conversationId, 2, continuationToken, 0); + var conversationMessages = new List(); + + for (var i = 0; i < 2; i++) + { + var conversationMessage = new ConversationMessage("Ronald", "skot", 1); + conversationMessages.Add(conversationMessage); + } + + _messageStoreMock.Setup(x => x.GetMessages(parameters)) + .ReturnsAsync( + new GetMessagesResult(conversationMessages, nextContinuationToken)); + + var expectedResult = new GetMessagesResult(conversationMessages, nextContinuationToken); + var actualResult = await _conversationService.GetMessages(parameters); + + Assert.Equal(expectedResult, actualResult); + } + + [Fact] + + public async Task GetConversationMessages_NoContinuationToken() + { + const string conversationId = "_karim_Ronald"; + const string continuationToken = ""; + var parameters = new GetMessagesParameters(conversationId, 2, continuationToken, 0); + var conversationMessages = new List(); + + for (var i = 0; i < 2; i++) + { + var conversationMessage = new ConversationMessage("Ronald", "skot", 1); + conversationMessages.Add(conversationMessage); + } + + const string nextContinuationToken = "anotherWeirdToken"; + _messageStoreMock.Setup(x => x.GetMessages(parameters)) + .ReturnsAsync( + new GetMessagesResult(conversationMessages, nextContinuationToken)); + + var expectedResult = new GetMessagesResult(conversationMessages, nextContinuationToken); + var actualResult = await _conversationService.GetMessages(parameters); + + Assert.Equal(expectedResult, actualResult); + } + + + [Fact] + + public async Task GetConversationMessages_NoContinuationTokenReturned() + { + const string conversationId = "_karim_Ronald"; + const string continuationToken = ""; + var parameters = new GetMessagesParameters(conversationId, 2, continuationToken, 0); + var conversationMessages = new List(); + + for (var i = 0; i < 2; i++) + { + var conversationMessage = new ConversationMessage("Ronald", "skot", 1); + conversationMessages.Add(conversationMessage); + } + + _messageStoreMock.Setup(x => x.GetMessages(parameters)) + .ReturnsAsync( + new GetMessagesResult(conversationMessages, null)); + + var expectedResult = new GetMessagesResult(conversationMessages, null); + var actualResult = await _conversationService.GetMessages(parameters); + + Assert.Equal(expectedResult, actualResult); + } + + [Fact] + + public async Task GetAllConversations_Success() + { + const string username = "jad"; + const string continuationToken = "someWeirdString"; + const string nextContinuationToken = "anotherWeirdToken"; + var participants1 = new List(); + var participants2 = new List(); + + participants1.Add(new Profile("jad", "mike", "o hearn", "1234")); + participants1.Add(new Profile("karim", "karim", "haddad", "1234")); + participants2.Add(new Profile("jad", "mike", "o hearn", "1234")); + participants2.Add(new Profile("ronald", "ronald", "haddad", "1234")); + + var conversation1 = new UserConversation("_jad_ronald", participants1, 1000, "jad"); + var conversation2 = new UserConversation("_jad_karim", participants2, 1001, "jad"); + var conversations = new List { conversation1, conversation2 }; + var parameters = new GetConversationsParameters(username, 2, continuationToken, 0); + + _conversationStoreMock.Setup(x => x.GetConversations(parameters)) + .ReturnsAsync( + new GetConversationsResult(conversations, nextContinuationToken)); + + var expectedResult = new GetConversationsResult(conversations, nextContinuationToken); + var actualResult = await _conversationService.GetConversations(parameters); + + Assert.Equivalent(expectedResult, actualResult); + } + + [Fact] + + public async Task GetAllConversations_NoContinuationToken() + { + const string username = "jad"; + const string continuationToken = ""; + const string nextContinuationToken = "anotherWeirdToken"; + var participants1 = new List(); + var participants2 = new List(); + + participants1.Add(new Profile("jad", "mike", "o hearn", "1234")); + participants1.Add(new Profile("karim", "karim", "haddad", "1234")); + participants2.Add(new Profile("jad", "mike", "o hearn", "1234")); + participants2.Add(new Profile("ronald", "ronald", "haddad", "1234")); + + var conversation1 = new UserConversation("_jad_ronald", participants1, 1000, "jad"); + var conversation2 = new UserConversation("_jad_karim", participants2, 1001, "jad"); + var conversations = new List { conversation1, conversation2 }; + var parameters = new GetConversationsParameters(username, 2, continuationToken, 0); + + _conversationStoreMock.Setup(x => x.GetConversations(parameters)) + .ReturnsAsync( + new GetConversationsResult(conversations, nextContinuationToken)); + + var expectedResult = new GetConversationsResult(conversations, nextContinuationToken); + var actualResult = await _conversationService.GetConversations(parameters); + + Assert.Equivalent(expectedResult, actualResult); + } + + [Fact] + public async Task GetAllConversations_NoContinuationTokenReturned() + { + const string username = "jad"; + const string continuationToken = ""; + var participants1 = new List(); + var participants2 = new List(); + + participants1.Add(new Profile("jad", "mike", "o hearn", "1234")); + participants1.Add(new Profile("karim", "karim", "haddad", "1234")); + participants2.Add(new Profile("jad", "mike", "o hearn", "1234")); + participants2.Add(new Profile("ronald", "ronald", "haddad", "1234")); + + var conversation1 = new UserConversation("_jad_ronald", participants1, 1000, "jad"); + var conversation2 = new UserConversation("_jad_karim", participants2, 1001, "jad"); + var conversations = new List { conversation1, conversation2 }; + var parameters = new GetConversationsParameters(username, 2, continuationToken, 0); + + _conversationStoreMock.Setup(x => x.GetConversations(parameters)) + .ReturnsAsync( + new GetConversationsResult(conversations, null)); + + var expectedResult = new GetConversationsResult(conversations, null); + var actualResult = await _conversationService.GetConversations(parameters); + + Assert.Equivalent(expectedResult, actualResult); + } + + [Fact] + public async Task EnqueueMessage_Success() + { + const string conversationId = "_jad_karim"; + var message = new Message("123", "jad", conversationId, "hello", 1000); + + _messageStoreMock.Setup(x => x.GetMessage(message.ConversationId, message.MessageId)) + .ThrowsAsync(new MessageNotFoundException("message not found")); + + var exception = await Record.ExceptionAsync( + async () => await _conversationService.EnqueueAddMessage(message)); + Assert.Null(exception); + + _addMessageServiceBusPublisherMock.Verify(x => x.Send(message), Times.Once); + } + + [Fact] + public async Task EnqueueMessage_MessageAlreadyExists() + { + const string conversationId = "_jad_karim"; + var message = new Message("123", "jad", conversationId, "hello", 1000); + + _messageStoreMock.Setup(x => x.GetMessage(message.ConversationId, message.MessageId)) + .ReturnsAsync(message); + + await Assert.ThrowsAsync (async () => + { + await _conversationService.EnqueueAddMessage(message); + }); + + _addMessageServiceBusPublisherMock.Verify(x => x.Send(message), Times.Never); + } + + [Fact] + public async Task EnqueueStartConversation_Success() + { + const string conversationId = "_jad_karim"; + var participantsList = new List { "jad", "karim" }; + var message = new Message("123", "jad", conversationId, "hello", 1000); + var startConversationParameters = new StartConversationParameters(message.MessageId, "jad", "hello", 1000, participantsList); + + _messageStoreMock.Setup(x => x.GetMessage(message.ConversationId, message.MessageId)) + .ThrowsAsync(new MessageNotFoundException("message not found")); + + var exception = await Record.ExceptionAsync( + async () => await _conversationService.EnqueueStartConversation(startConversationParameters)); + + Assert.Null(exception); + _startConversationServiceBusPublisherMock.Verify(x => x.Send(startConversationParameters), Times.Once); + } + + [Fact] + public async Task EnqueueStartConversation_MessageAlreadyExists() + { + const string conversationId = "_jad_karim"; + var participantsList = new List { "jad", "karim" }; + var message = new Message("123", "jad", conversationId, "hello", 1000); + var startConversationParameters = new StartConversationParameters(message.MessageId, "jad", "hello", 1000, participantsList); + + _messageStoreMock.Setup(x => x.GetMessage(message.ConversationId, message.MessageId)) + .ReturnsAsync(message); + + await Assert.ThrowsAsync (async () => + { + await _conversationService.EnqueueStartConversation(startConversationParameters); + }); + _startConversationServiceBusPublisherMock.Verify(x => x.Send(startConversationParameters), Times.Never); + } + + [Fact] + public async Task EnqueueStartConversation_ReceiverNotFound() + { + const string conversationId = "_jad_karim"; + var participantsList = new List { "jad", "karim" }; + var message = new Message("123", "jad", conversationId, "hello", 1000); + var startConversationParameters = new StartConversationParameters(message.MessageId, "jad", "hello", 1000, participantsList); + + _profileStoreMock.Setup(x => x.GetProfile("jad")) + .ReturnsAsync(new Profile("jad", "mike", "o hearn", "1234")); + _profileStoreMock.Setup(x => x.GetProfile("karim")) + .ThrowsAsync(new ProfileNotFoundException("profile not found")); + + await Assert.ThrowsAsync (async () => + { + await _conversationService.EnqueueStartConversation(startConversationParameters); + }); + + _startConversationServiceBusPublisherMock.Verify(x => x.Send(startConversationParameters), Times.Never); + } + + [Fact] + public async Task EnqueueStartConversation_SenderNotFound() + { + const string conversationId = "_jad_karim"; + var participantsList = new List { "toufic", "karim" }; + var message = new Message("123", "jad", conversationId, "hello", 1000); + var startConversationParameters = new StartConversationParameters(message.MessageId, "jad", "hello", 1000, participantsList); + + await Assert.ThrowsAsync (async () => + { + await _conversationService.EnqueueStartConversation(startConversationParameters); + }); + + _startConversationServiceBusPublisherMock.Verify(x => x.Send(startConversationParameters), Times.Never); + } + + [Fact] + public async Task EnqueueStartConversation_DuplicateParticipants() + { + const string conversationId = "_jad_karim"; + var participantsList = new List { "jad", "jad" }; + var message = new Message("123", "jad", conversationId, "hello", 1000); + var startConversationParameters = new StartConversationParameters(message.MessageId, "jad", "hello", 1000, participantsList); + + await Assert.ThrowsAsync (async () => + { + await _conversationService.EnqueueStartConversation(startConversationParameters); + }); + + _startConversationServiceBusPublisherMock.Verify(x => x.Send(startConversationParameters), Times.Never); + } + + +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web.Tests/Services/ImageServiceTests.cs b/ChatApplication/ChatApplication.Web.Tests/Services/ImageServiceTests.cs new file mode 100644 index 0000000..ce298d2 --- /dev/null +++ b/ChatApplication/ChatApplication.Web.Tests/Services/ImageServiceTests.cs @@ -0,0 +1,56 @@ +using ChatApplication.Services; +using ChatApplication.Storage; +using ChatApplication.Utils; +using Microsoft.Extensions.Logging; +using Moq; + +namespace ChatApplication.Web.Tests.Services; + +public class ImageServiceTests +{ + private readonly Mock _imageStoreMock = new(); + private readonly ImageService _imageService; + private readonly Mock> _logger = new(); + + public ImageServiceTests() + { + _imageService = new ImageService(_imageStoreMock.Object, _logger.Object); + } + + [Fact] + public async Task GetImage() + { + var image = new byte[]{0,1,2}; + + _imageStoreMock.Setup(m => m.GetImage("12345")) + .ReturnsAsync(new Image(image, "image/png")); + + var actualImage = await _imageService.GetImage("12345"); + + Assert.Equal(image, actualImage?.ImageData); + } + + [Fact] + public async Task GetImage_NotFound() + { + _imageStoreMock.Setup(m => m.GetImage("12345")) + .ReturnsAsync((Image?)null); + + var actualImage = await _imageService.GetImage("12345"); + + Assert.Null(actualImage); + } + + [Fact] + public async Task AddImage() + { + var image = new byte[]{0,1,2}; + var stream = new MemoryStream(image); + + _imageStoreMock.Setup(m => m.AddImage(It.IsAny(), stream, "image/png")); + + await _imageService.AddImage(stream, "image/png"); + + _imageStoreMock.Verify(mock => mock.AddImage(It.IsAny(), stream, "image/png"), Times.Once); + } +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web.Tests/Services/ProfileServiceTests.cs b/ChatApplication/ChatApplication.Web.Tests/Services/ProfileServiceTests.cs new file mode 100644 index 0000000..577a52c --- /dev/null +++ b/ChatApplication/ChatApplication.Web.Tests/Services/ProfileServiceTests.cs @@ -0,0 +1,75 @@ +using ChatApplication.Exceptions; +using ChatApplication.Services; +using ChatApplication.Storage; +using ChatApplication.Utils; +using ChatApplication.Web.Dtos; +using Moq; + +namespace ChatApplication.Web.Tests.Services; + +public class ProfileServiceTests +{ + + + private readonly Mock _profileStoreMock = new(); + private readonly Mock _imageStoreMock = new(); + private readonly ProfileService _profileService; + + public ProfileServiceTests() + { + _profileService = new ProfileService(_profileStoreMock.Object, _imageStoreMock.Object); + } + + [Fact] + public async Task GetProfile() + { + var profile = new Profile("foobar", "Foo", "Bar", "12345"); + + _profileStoreMock.Setup(m => m.GetProfile(profile.Username)) + .ReturnsAsync(profile); + + var actualProfile = await _profileService.GetProfile(profile.Username); + + Assert.Equivalent(profile, actualProfile); + } + + [Fact] + public async Task GetProfile_NotFound() + { + _profileStoreMock.Setup(m => m.GetProfile("foobar")) + .ThrowsAsync(new ProfileNotFoundException("Profile not found")); + + await Assert.ThrowsAsync(async () => await _profileService.GetProfile("foobar")); + } + + [Fact] + public async Task AddProfile() + { + var profile = new Profile("foobar", "Foo", "Bar", "12345"); + var image = new byte[]{0,1,2}; + + _imageStoreMock.Setup(m => m.GetImage(profile.ProfilePictureId)) + .ReturnsAsync(new Image(image, "image/png")); + _profileStoreMock.Setup(m => m.AddProfile(profile)); + + await _profileService.AddProfile(profile); + + _profileStoreMock.Verify(mock => mock.AddProfile(profile), Times.Once); + } + + /*[Fact] + public async Task AddProfile_InvalidImage() + { + var profile = new Profile("foobar", "Foo", "Bar", "12345"); + _imageStoreMock.Setup(m => m.GetImage(profile.ProfilePictureId)) + .ThrowsAsync(new ArgumentException()); + await Assert.ThrowsAsync(async () => + { + await _profileService.AddProfile(profile); + }); + _profileStoreMock.Verify(mock => mock.AddProfile(profile), Times.Never); + }*/ + + //code commented for the sake of the functional tests + +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web.Tests/Storage/BlobImageStoreUnitTests.cs b/ChatApplication/ChatApplication.Web.Tests/Storage/BlobImageStoreUnitTests.cs new file mode 100644 index 0000000..01cbdde --- /dev/null +++ b/ChatApplication/ChatApplication.Web.Tests/Storage/BlobImageStoreUnitTests.cs @@ -0,0 +1,52 @@ +using System.Text; +using Azure; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using ChatApplication.Exceptions.StorageExceptions; +using ChatApplication.Storage; +using Moq; +using Moq.Protected; + +namespace ChatApplication.Web.Tests.Storage; + +public class BlobImageStoreTests +{ + private readonly Mock _blobContainerClientMock; + private readonly BlobImageStore _blobImageStore; + + public BlobImageStoreTests() + { + _blobContainerClientMock = new Mock(); + _blobImageStore = new BlobImageStore(_blobContainerClientMock.Object); + } + + [Fact] + public async Task RequestFailedExceptionIsHandled() + { + const string blobName = "testBlob"; + var contentType = "image/jpeg"; + var memoryStream = new MemoryStream(Encoding.UTF8.GetBytes("Test data")); + + var blobClientMock = new Mock(); + + _blobContainerClientMock.Setup(x => x.GetBlobClient(blobName)).Returns(blobClientMock.Object); + + blobClientMock.Setup(x => x.UploadAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new RequestFailedException(500, "Fake RequestFailedException")); + blobClientMock.Setup(x => x.ExistsAsync(It.IsAny())) + .ThrowsAsync(new RequestFailedException(500, "Fake RequestFailedException")); + blobClientMock + .Setup(x => x.DownloadAsync(It.IsAny())) + .ThrowsAsync(new RequestFailedException(500, "Fake RequestFailedException")); + blobClientMock + .Setup(x => x.DeleteAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new RequestFailedException(500, "Fake RequestFailedException")); + + + + //await Assert.ThrowsAsync(() => _blobImageStore.AddImage(blobName, memoryStream, contentType)); + await Assert.ThrowsAsync(() => _blobImageStore.GetImage(blobName)); + await Assert.ThrowsAsync(() => _blobImageStore.DeleteImage(blobName)); + } + +} diff --git a/ChatApplication/ChatApplication.Web.Tests/Storage/CosmosConversationStoreUnitTests.cs b/ChatApplication/ChatApplication.Web.Tests/Storage/CosmosConversationStoreUnitTests.cs new file mode 100644 index 0000000..5e8e6a3 --- /dev/null +++ b/ChatApplication/ChatApplication.Web.Tests/Storage/CosmosConversationStoreUnitTests.cs @@ -0,0 +1,53 @@ +using System.Net; +using ChatApplication.Exceptions.StorageExceptions; +using ChatApplication.Storage; +using ChatApplication.Storage.Entities; +using ChatApplication.Web.Dtos; +using Microsoft.Azure.Cosmos; +using Moq; + +namespace ChatApplication.Web.Tests.Storage; + +public class CosmosConversationStoreUnitTests +{ + private readonly Mock _cosmosClientMock; + private readonly Mock _containerMock; + private readonly CosmosConversationStore _cosmosConversationStore; + + public CosmosConversationStoreUnitTests() + { + _cosmosClientMock = new Mock(); + _containerMock = new Mock(); + _cosmosClientMock.Setup(client => client.GetDatabase(It.IsAny()).GetContainer(It.IsAny())) + .Returns(_containerMock.Object); + _cosmosConversationStore = new CosmosConversationStore(_cosmosClientMock.Object); + } + + [Fact] + + public async Task CosmosExceptionIsHandled() + { + + //_containerMock.Setup(x => x.CreateItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + //.ThrowsAsync(new CosmosException("Fake Exception", HttpStatusCode.InternalServerError, 0, "", 0)); + + //We can't seem to make the above work. It never throws the exception. + _containerMock.Setup(x => x.ReadItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new CosmosException("Fake Exception", HttpStatusCode.InternalServerError, 0, "", 0)); + _containerMock.Setup(x => x.ReplaceItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new CosmosException("Fake Exception", HttpStatusCode.InternalServerError, 0, "", 0)); + _containerMock.Setup(x => x.DeleteItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new CosmosException("Fake Exception", HttpStatusCode.InternalServerError, 0, "", 0)); + + var userConversation = new UserConversation("conversationId", new List(), 123456789, "username"); + + //await Assert.ThrowsAsync(() => _cosmosConversationStore.CreateUserConversation(userConversation)); + await Assert.ThrowsAsync(() => _cosmosConversationStore.GetUserConversation(userConversation.Username, userConversation.ConversationId)); + await Assert.ThrowsAsync(() => _cosmosConversationStore.UpdateConversationLastMessageTime(userConversation, userConversation.LastMessageTime)); + await Assert.ThrowsAsync(() => _cosmosConversationStore.DeleteUserConversation(userConversation)); + } + + + + +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web.Tests/Storage/CosmosMessageStoreUnitTests.cs b/ChatApplication/ChatApplication.Web.Tests/Storage/CosmosMessageStoreUnitTests.cs new file mode 100644 index 0000000..ae0e1f4 --- /dev/null +++ b/ChatApplication/ChatApplication.Web.Tests/Storage/CosmosMessageStoreUnitTests.cs @@ -0,0 +1,42 @@ +using System.Net; +using ChatApplication.Exceptions.StorageExceptions; +using ChatApplication.Storage; +using ChatApplication.Storage.Entities; +using ChatApplication.Web.Dtos; +using Microsoft.Azure.Cosmos; +using Moq; + +namespace ChatApplication.Web.Tests.Storage; + +public class CosmosMessageStoreUnitTests +{ + private readonly Mock _cosmosClientMock; + private readonly Mock _containerMock; + private readonly CosmosMessageStore _cosmosMessageStore; + + public CosmosMessageStoreUnitTests() + { + _cosmosClientMock = new Mock(); + _containerMock = new Mock(); + _cosmosClientMock.Setup(client => client.GetDatabase(It.IsAny()).GetContainer(It.IsAny())) + .Returns(_containerMock.Object); + _cosmosMessageStore = new CosmosMessageStore(_cosmosClientMock.Object); + } + + [Fact] + public async Task CosmosExceptionIsHandled() + { + //_containerMock.Setup(x => x.CreateItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + //.ThrowsAsync(new CosmosException("Fake Exception", HttpStatusCode.InternalServerError, 0, "", 0)); + _containerMock.Setup(x => x.ReadItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new CosmosException("Fake Exception", HttpStatusCode.InternalServerError, 0, "", 0)); + _containerMock.Setup(x => x.DeleteItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new CosmosException("Fake Exception", HttpStatusCode.InternalServerError, 0, "", 0)); + + var message = new Message("messageId", "senderUsername", "text", "conversationId", 123456789); + + //await Assert.ThrowsAsync(() => _cosmosMessageStore.AddMessage(message)); + await Assert.ThrowsAsync(() => _cosmosMessageStore.GetMessage(message.ConversationId, message.MessageId)); + await Assert.ThrowsAsync(() => _cosmosMessageStore.DeleteMessage(message)); + } +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web.Tests/Storage/CosmosProfileStoreUnitTests.cs b/ChatApplication/ChatApplication.Web.Tests/Storage/CosmosProfileStoreUnitTests.cs new file mode 100644 index 0000000..49762fa --- /dev/null +++ b/ChatApplication/ChatApplication.Web.Tests/Storage/CosmosProfileStoreUnitTests.cs @@ -0,0 +1,42 @@ +using System.Net; +using ChatApplication.Exceptions.StorageExceptions; +using ChatApplication.Storage; +using ChatApplication.Storage.Entities; +using ChatApplication.Web.Dtos; +using Microsoft.Azure.Cosmos; +using Moq; + +namespace ChatApplication.Web.Tests.Storage; + +public class CosmosProfileStoreUnitTests +{ + private readonly Mock _cosmosClientMock; + private readonly Mock _containerMock; + private readonly CosmosProfileStore _cosmosProfileStore; + + public CosmosProfileStoreUnitTests() + { + _cosmosClientMock = new Mock(); + _containerMock = new Mock(); + _cosmosClientMock.Setup(client => client.GetDatabase(It.IsAny()).GetContainer(It.IsAny())) + .Returns(_containerMock.Object); + _cosmosProfileStore = new CosmosProfileStore(_cosmosClientMock.Object); + } + + [Fact] + public async Task CosmosExceptionIsHandled() + { + //_containerMock.Setup(x => x.CreateItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + //.ThrowsAsync(new CosmosException("Fake Exception", HttpStatusCode.InternalServerError, 0, "", 0)); + _containerMock.Setup(x => x.ReadItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new CosmosException("Fake Exception", HttpStatusCode.InternalServerError, 0, "", 0)); + _containerMock.Setup(x => x.DeleteItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new CosmosException("Fake Exception", HttpStatusCode.InternalServerError, 0, "", 0)); + + var profile = new Profile("username", "firstName", "lastName", "profilePictureId"); + + //await Assert.ThrowsAsync(() => _cosmosProfileStore.AddProfile(profile)); + await Assert.ThrowsAsync(() => _cosmosProfileStore.GetProfile(profile.Username)); + await Assert.ThrowsAsync(() => _cosmosProfileStore.DeleteProfile(profile.Username)); + } +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web.Tests/Usings.cs b/ChatApplication/ChatApplication.Web.Tests/Usings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/ChatApplication/ChatApplication.Web.Tests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/ChatApplication.Web.csproj b/ChatApplication/ChatApplication.Web/ChatApplication.Web.csproj new file mode 100644 index 0000000..e01fd0a --- /dev/null +++ b/ChatApplication/ChatApplication.Web/ChatApplication.Web.csproj @@ -0,0 +1,25 @@ + + + + net7.0 + enable + enable + ChatApplication + + + + + + + + + + + + + + + + + + diff --git a/ChatApplication/ChatApplication.Web/Configuration/BlobSettings.cs b/ChatApplication/ChatApplication.Web/Configuration/BlobSettings.cs new file mode 100644 index 0000000..ca6806d --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Configuration/BlobSettings.cs @@ -0,0 +1,7 @@ +namespace ChatApplication.Configuration; + +public class BlobSettings +{ + public string ConnectionString { get; init; } + public string ContainerName { get; init; } +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Configuration/CosmosSettings.cs b/ChatApplication/ChatApplication.Web/Configuration/CosmosSettings.cs new file mode 100644 index 0000000..b1fda51 --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Configuration/CosmosSettings.cs @@ -0,0 +1,6 @@ +namespace ChatApplication.Configuration; + +public class CosmosSettings +{ + public string ConnectionString { get; init; } +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Configuration/SQLSettings.cs b/ChatApplication/ChatApplication.Web/Configuration/SQLSettings.cs new file mode 100644 index 0000000..fe9898d --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Configuration/SQLSettings.cs @@ -0,0 +1,6 @@ +namespace ChatApplication.Configuration; + +public class SQLSettings +{ + public string ConnectionString { get; set; } +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Configuration/ServiceBusSettings.cs b/ChatApplication/ChatApplication.Web/Configuration/ServiceBusSettings.cs new file mode 100644 index 0000000..17997ab --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Configuration/ServiceBusSettings.cs @@ -0,0 +1,8 @@ +namespace ChatApplication.Configuration; + +public class ServiceBusSettings +{ + public string ConnectionString { get; set; } + public string AddMessageQueueName { get; set; } + public string StartConversationQueueName { get; set; } +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Controllers/ConversationController.cs b/ChatApplication/ChatApplication.Web/Controllers/ConversationController.cs new file mode 100644 index 0000000..47c5c14 --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Controllers/ConversationController.cs @@ -0,0 +1,155 @@ +using System.Diagnostics; +using System.Net; +using ChatApplication.Exceptions; +using ChatApplication.Exceptions.ConversationParticipantsExceptions; +using ChatApplication.Services; +using ChatApplication.Web.Dtos; +using Microsoft.ApplicationInsights; +using Microsoft.AspNetCore.Mvc; + +namespace ChatApplication.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class ConversationsController : ControllerBase +{ + private readonly IConversationService _conversationService; + private readonly TelemetryClient _telemetryClient; + private readonly ILogger _logger; + + public ConversationsController(IConversationService conversationService, TelemetryClient telemetryClient, + ILogger logger) + { + _conversationService = conversationService; + _telemetryClient = telemetryClient; + _logger = logger; + } + + [HttpPost("{conversationId}/messages")] + public async Task> AddMessage(SendMessageRequest sendMessageRequest, + string conversationId) + { + using (_logger.BeginScope("Adding message {Message} to conversation {ConversationId}", sendMessageRequest, + conversationId)) + { + if (!sendMessageRequest.IsValid(out var error)) + { + return BadRequest(error); + } + + var time = DateTimeOffset.UtcNow; + Console.WriteLine("time of added message is "+time.ToUnixTimeMilliseconds()); + var message = new Message(sendMessageRequest.Id, sendMessageRequest.SenderUsername, conversationId, + sendMessageRequest.Text, time.ToUnixTimeMilliseconds()); + + try + { + var stopWatch = new Stopwatch(); + await _conversationService.EnqueueAddMessage(message); + _telemetryClient.TrackMetric("ConversationService.EnqueueAddMessage.Time", + stopWatch.ElapsedMilliseconds); + _telemetryClient.TrackEvent("MessageAdded"); + _logger.LogInformation("Message added"); + + return CreatedAtAction(nameof(GetMessages), new { conversationId }, + new SendMessageResponse(message.CreatedUnixTime)); + } + catch (ConversationNotFoundException) + { + return NotFound($"A conversation with id : {conversationId} was not found"); + } + catch (MessageAlreadyExistsException) + { + return Conflict($"A message with id : {message.MessageId} already exists "); + } + } + } + + [HttpPost] + public async Task> StartConversation( + StartConversationRequest conversationRequest) + { + if (!conversationRequest.IsValid(out var error)) + { + return BadRequest(error); + } + + try + { + var createdTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + Console.WriteLine("Created time is: " + createdTime); + var stopWatch = new Stopwatch(); + var startConversationParameters = new StartConversationParameters( + conversationRequest.FirstMessage.Id, conversationRequest.FirstMessage.SenderUsername, + conversationRequest.FirstMessage.Text, createdTime, conversationRequest.Participants); + + var id = await _conversationService.EnqueueStartConversation(startConversationParameters); + + _telemetryClient.TrackMetric("ConversationService.EnqueueStartConversation.Time", stopWatch.ElapsedMilliseconds); + _telemetryClient.TrackEvent("ConversationStarted"); + _logger.LogInformation("Conversation started with id {conversationId}", id); + + var response = new StartConversationResponse(id, createdTime, conversationRequest.Participants); + + return CreatedAtAction(nameof(GetConversations), + new { username = conversationRequest.FirstMessage.SenderUsername }, response); + } + catch (Exception e) when (e is SenderNotFoundException or DuplicateParticipantException) + { + return BadRequest(e.Message); + } + catch (ProfileNotFoundException e) + { + return NotFound(e.Message); + } + catch (MessageAlreadyExistsException e) + { + return Conflict(e.Message); + } + } + + [HttpGet("{conversationId}/messages")] + public async Task GetMessages(string conversationId, int limit = 50, + string continuationToken = "", long lastSeenMessageTime = 0) + { + var stopWatch = new Stopwatch(); + var getMessagesParameters = + new GetMessagesParameters(conversationId, limit, continuationToken, lastSeenMessageTime); + + var getMessagesResult = await _conversationService.GetMessages(getMessagesParameters); + _telemetryClient.TrackMetric("ConversationService.GetConversationMessages.Time", stopWatch.ElapsedMilliseconds); + var nextUri = ""; + + if (getMessagesResult.ContinuationToken != null) + { + nextUri = $"/api/Conversations/{conversationId}/messages?limit={limit}&continuationToken={WebUtility.UrlEncode(getMessagesResult.ContinuationToken)}&lastSeenMessageTime={lastSeenMessageTime}"; + } + + var response = new GetMessagesResponse(getMessagesResult.Messages, nextUri); + + return response; + } + + [HttpGet] + public async Task> GetConversations(string username, int limit = 50, + string continuationToken = "", long lastSeenConversationTime = 0) + { + var stopWatch = new Stopwatch(); + var getConversationsParameters = + new GetConversationsParameters(username, limit, continuationToken, lastSeenConversationTime); + + var getConversationsResult = await _conversationService.GetConversations(getConversationsParameters); + _telemetryClient.TrackMetric("ConversationService.GetConversations.Time", stopWatch.ElapsedMilliseconds); + + var nextUri = ""; + + if (getConversationsResult.ContinuationToken != null) + { + nextUri = $"/api/Conversations?username={username}&limit={limit}&continuationToken={WebUtility.UrlEncode(getConversationsResult.ContinuationToken)}&lastSeenConversationTime={lastSeenConversationTime}"; + } + + var response = new GetConversationsResponse(getConversationsResult.ToMetadata(username), nextUri); + + return response; + } +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Controllers/ImagesController.cs b/ChatApplication/ChatApplication.Web/Controllers/ImagesController.cs new file mode 100644 index 0000000..7f2aeea --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Controllers/ImagesController.cs @@ -0,0 +1,69 @@ +using System.Diagnostics; +using ChatApplication.Exceptions; +using ChatApplication.Services; +using ChatApplication.Utils; +using ChatApplication.Web.Dtos; +using Microsoft.ApplicationInsights; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.Cosmos.Linq; + +namespace ChatApplication.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class ImagesController : ControllerBase +{ + private readonly IImageService _imageService; + private readonly ILogger _logger; + private readonly TelemetryClient _telemetryClient; + + public ImagesController(IImageService imageService, ILogger logger, TelemetryClient telemetryClient) + { + _imageService = imageService; + _logger = logger; + _telemetryClient = telemetryClient; + } + + [HttpGet("{id}")] + public async Task DownloadImage(string id) + { + try + { + var stopWatch = Stopwatch.StartNew(); + var image = await _imageService.GetImage(id); + _telemetryClient.TrackMetric("ImageService.GetImage.Time", stopWatch.ElapsedMilliseconds); + return new FileContentResult(image!.ImageData, image.ContentType); + } + catch (ArgumentException) + { + return BadRequest($"Invalid image id : {id}"); + } + catch (ImageNotFoundException) + { + return NotFound($"Image with id :{id} not found"); + } + } + + [HttpPost] + public async Task> UploadImage([FromForm] UploadImageRequest request) + { + if (request.File.Length == 0) return new UploadImageResponse(""); + using (_logger.BeginScope("Uploading Image with name {Name}", request.File.FileName)) + { + using var stream = new MemoryStream(); + await request.File.CopyToAsync(stream); + if (stream.Length == 0) + { + return BadRequest($"File {request.File.FileName} is empty"); + } + + var stopWatch = Stopwatch.StartNew(); + var id = await _imageService.AddImage(stream, request.File.ContentType); + _logger.LogInformation("Image added with id {Id}", id); + _telemetryClient.TrackMetric("ImageService.AddImage.Time", stopWatch.ElapsedMilliseconds); + _telemetryClient.TrackEvent("ImageAdded"); + return CreatedAtAction(nameof(DownloadImage), new { id }, new UploadImageResponse(id)); + } + + } +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Controllers/ProfileController.cs b/ChatApplication/ChatApplication.Web/Controllers/ProfileController.cs new file mode 100644 index 0000000..64467b4 --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Controllers/ProfileController.cs @@ -0,0 +1,70 @@ +using System.Diagnostics; +using ChatApplication.Exceptions; +using ChatApplication.Services; +using ChatApplication.Web.Dtos; +using Microsoft.ApplicationInsights; +using Microsoft.AspNetCore.Mvc; + +namespace ChatApplication.Controllers; + + +[ApiController] +[Route("api/[controller]")] + +public class ProfileController : ControllerBase + +{ + private readonly IProfileService _profileService; + private readonly ILogger _logger; + private readonly TelemetryClient _telemetryClient; + + public ProfileController(IProfileService profileService, ILogger logger, TelemetryClient telemetryClient) + { + _profileService = profileService; + _logger = logger; + _telemetryClient = telemetryClient; + } + + [HttpGet("{username}")] + public async Task> GetProfile(string username) + { + try + { + var stopWatch = Stopwatch.StartNew(); + var profile = await _profileService.GetProfile(username); + _telemetryClient.TrackMetric("ProfileService.GetProfile.Time", stopWatch.ElapsedMilliseconds); + return Ok(profile); + } + catch (ProfileNotFoundException) + { + return NotFound($"A User with username {username} was not found"); + } + } + + [HttpPost] + public async Task> AddProfile(Profile profile) + { + using (_logger.BeginScope("Creating profile {Profile}", profile)) + { + try + { + var stopWatch = Stopwatch.StartNew(); + await _profileService.AddProfile(profile); + _telemetryClient.TrackMetric("ProfileService.AddProfile.Time", stopWatch.ElapsedMilliseconds); + _telemetryClient.TrackEvent("ProfileAdded"); + _logger.LogInformation("Profile created"); + return CreatedAtAction(nameof(GetProfile), new { username = profile.Username }, + profile); + } + catch (ImageNotFoundException) + { + return BadRequest( + $"There are no corresponding images for the profile with username: {profile.Username}"); + } + catch (ProfileAlreadyExistsException) + { + return Conflict($"A profile with username {profile.Username} already exists"); + } + } + } +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/DTOs/ConversationDTOs/ConversationMessage.cs b/ChatApplication/ChatApplication.Web/DTOs/ConversationDTOs/ConversationMessage.cs new file mode 100644 index 0000000..5988b81 --- /dev/null +++ b/ChatApplication/ChatApplication.Web/DTOs/ConversationDTOs/ConversationMessage.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatApplication.Web.Dtos; + +public record ConversationMessage( + [Required] string SenderUsername, + [Required] string Text, + [Required] long UnixTime +); \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/DTOs/ConversationDTOs/ConversationMetaData.cs b/ChatApplication/ChatApplication.Web/DTOs/ConversationDTOs/ConversationMetaData.cs new file mode 100644 index 0000000..bb31482 --- /dev/null +++ b/ChatApplication/ChatApplication.Web/DTOs/ConversationDTOs/ConversationMetaData.cs @@ -0,0 +1,7 @@ +namespace ChatApplication.Web.Dtos; + +public record ConversationMetaData( + string Id, + long LastModifiedUnixTime, + Profile Recipient +); \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/DTOs/ConversationDTOs/CreateConversationResponse.cs b/ChatApplication/ChatApplication.Web/DTOs/ConversationDTOs/CreateConversationResponse.cs new file mode 100644 index 0000000..e3dcacd --- /dev/null +++ b/ChatApplication/ChatApplication.Web/DTOs/ConversationDTOs/CreateConversationResponse.cs @@ -0,0 +1,5 @@ +using ChatApplication.Utils; + +namespace ChatApplication.Web.Dtos; + +public record StartConversationResponse(string Id, long CreatedUnixTime, List Participants); \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/DTOs/ConversationDTOs/GetConversationsParameters.cs b/ChatApplication/ChatApplication.Web/DTOs/ConversationDTOs/GetConversationsParameters.cs new file mode 100644 index 0000000..6250952 --- /dev/null +++ b/ChatApplication/ChatApplication.Web/DTOs/ConversationDTOs/GetConversationsParameters.cs @@ -0,0 +1,3 @@ +namespace ChatApplication.Web.Dtos; + +public record GetConversationsParameters(string Username, int Limit = 50, string ContinuationToken = "", long LastSeenConversationTime=0); \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/DTOs/ConversationDTOs/GetConversationsResponse.cs b/ChatApplication/ChatApplication.Web/DTOs/ConversationDTOs/GetConversationsResponse.cs new file mode 100644 index 0000000..89af4a8 --- /dev/null +++ b/ChatApplication/ChatApplication.Web/DTOs/ConversationDTOs/GetConversationsResponse.cs @@ -0,0 +1,6 @@ +namespace ChatApplication.Web.Dtos; + +public record GetConversationsResponse( + List Conversations, + string NextUri + ); \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/DTOs/ConversationDTOs/GetConversationsResult.cs b/ChatApplication/ChatApplication.Web/DTOs/ConversationDTOs/GetConversationsResult.cs new file mode 100644 index 0000000..e9c281f --- /dev/null +++ b/ChatApplication/ChatApplication.Web/DTOs/ConversationDTOs/GetConversationsResult.cs @@ -0,0 +1,30 @@ +namespace ChatApplication.Web.Dtos; + +public record GetConversationsResult( + List Conversations, + string? ContinuationToken +) +{ + public List ToMetadata(string senderUsername) + { + List metadata = new(); + foreach (var conversation in Conversations) + { + var recipient = new Profile("", "", "", ""); + foreach (var participant in conversation.Recipients.Where(participant => participant.Username != senderUsername)) + { + recipient = participant; + break; + } + ConversationMetaData conversationMetaData = new( + conversation.ConversationId, + conversation.LastMessageTime, + recipient + ); + metadata.Add(conversationMetaData); + } + + return metadata; + } + +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/DTOs/ConversationDTOs/StartConversationParameters.cs b/ChatApplication/ChatApplication.Web/DTOs/ConversationDTOs/StartConversationParameters.cs new file mode 100644 index 0000000..bb39bae --- /dev/null +++ b/ChatApplication/ChatApplication.Web/DTOs/ConversationDTOs/StartConversationParameters.cs @@ -0,0 +1,6 @@ +namespace ChatApplication.Web.Dtos; + +public record StartConversationParameters( + string messageId, string senderUsername, string messageContent, + long createdTime, List participants + ); \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/DTOs/ConversationDTOs/StartConversationRequest.cs b/ChatApplication/ChatApplication.Web/DTOs/ConversationDTOs/StartConversationRequest.cs new file mode 100644 index 0000000..64d2f6d --- /dev/null +++ b/ChatApplication/ChatApplication.Web/DTOs/ConversationDTOs/StartConversationRequest.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatApplication.Web.Dtos; + +public record StartConversationRequest( + [Required] List Participants, + SendMessageRequest FirstMessage +) +{ + public bool IsValid(out string? error) + { + if (Participants.Count < 2) + { + error = "Participants must be at least 2"; + return false; + } + + if (!FirstMessage.IsValid(out error)) + { + return false; + } + + error = null; + return true; + } +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/DTOs/ConversationDTOs/UserConversation.cs b/ChatApplication/ChatApplication.Web/DTOs/ConversationDTOs/UserConversation.cs new file mode 100644 index 0000000..7e008db --- /dev/null +++ b/ChatApplication/ChatApplication.Web/DTOs/ConversationDTOs/UserConversation.cs @@ -0,0 +1,18 @@ +namespace ChatApplication.Web.Dtos; + +public record UserConversation +{ + public UserConversation(string conversationId, List recipients, long lastMessageTime, string username) + { + ConversationId = conversationId; + Recipients = recipients; + LastMessageTime = lastMessageTime; + Username = username; + } + + public string ConversationId { get; init; } + public List Recipients { get; init; } + public long LastMessageTime { get; set; } + + public string Username { get; init; } +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/DTOs/ImageDTOs/UploadImageRequest.cs b/ChatApplication/ChatApplication.Web/DTOs/ImageDTOs/UploadImageRequest.cs new file mode 100644 index 0000000..473ecc7 --- /dev/null +++ b/ChatApplication/ChatApplication.Web/DTOs/ImageDTOs/UploadImageRequest.cs @@ -0,0 +1,3 @@ +namespace ChatApplication.Web.Dtos; + +public record UploadImageRequest(IFormFile File); \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/DTOs/ImageDTOs/UploadImageResponse.cs b/ChatApplication/ChatApplication.Web/DTOs/ImageDTOs/UploadImageResponse.cs new file mode 100644 index 0000000..c4ba44b --- /dev/null +++ b/ChatApplication/ChatApplication.Web/DTOs/ImageDTOs/UploadImageResponse.cs @@ -0,0 +1,3 @@ +namespace ChatApplication.Web.Dtos; + +public record UploadImageResponse(string ImageId); \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/DTOs/MessageDTOs/GetMessagesParameters.cs b/ChatApplication/ChatApplication.Web/DTOs/MessageDTOs/GetMessagesParameters.cs new file mode 100644 index 0000000..b7eac04 --- /dev/null +++ b/ChatApplication/ChatApplication.Web/DTOs/MessageDTOs/GetMessagesParameters.cs @@ -0,0 +1,4 @@ +namespace ChatApplication.Web.Dtos; + +public record GetMessagesParameters(string ConversationId, int Limit = 50, + string ContinuationToken = "", long LastSeenMessageTime = 0); \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/DTOs/MessageDTOs/GetMessagesResponse.cs b/ChatApplication/ChatApplication.Web/DTOs/MessageDTOs/GetMessagesResponse.cs new file mode 100644 index 0000000..e679075 --- /dev/null +++ b/ChatApplication/ChatApplication.Web/DTOs/MessageDTOs/GetMessagesResponse.cs @@ -0,0 +1,6 @@ +namespace ChatApplication.Web.Dtos; + +public record GetMessagesResponse( + List Messages, + string NextUri +); \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/DTOs/MessageDTOs/GetMessagesResult.cs b/ChatApplication/ChatApplication.Web/DTOs/MessageDTOs/GetMessagesResult.cs new file mode 100644 index 0000000..ee01e55 --- /dev/null +++ b/ChatApplication/ChatApplication.Web/DTOs/MessageDTOs/GetMessagesResult.cs @@ -0,0 +1,6 @@ +namespace ChatApplication.Web.Dtos; + +public record GetMessagesResult( + List Messages, + string? ContinuationToken +); \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/DTOs/MessageDTOs/Message.cs b/ChatApplication/ChatApplication.Web/DTOs/MessageDTOs/Message.cs new file mode 100644 index 0000000..3c4236c --- /dev/null +++ b/ChatApplication/ChatApplication.Web/DTOs/MessageDTOs/Message.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatApplication.Web.Dtos; + +public record Message( + [Required] string MessageId, + [Required] string SenderUsername, + [Required] string ConversationId, + [Required] string Text, + [Required] long CreatedUnixTime + +); \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/DTOs/MessageDTOs/SendMessageRequest.cs b/ChatApplication/ChatApplication.Web/DTOs/MessageDTOs/SendMessageRequest.cs new file mode 100644 index 0000000..df657a3 --- /dev/null +++ b/ChatApplication/ChatApplication.Web/DTOs/MessageDTOs/SendMessageRequest.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatApplication.Web.Dtos; + +public record SendMessageRequest( + [Required] string Id, + [Required] string SenderUsername, + [Required] string Text +) +{ + public bool IsValid(out string? error) + { + if (string.IsNullOrWhiteSpace(Id)) + { + error = "Id must not be empty"; + return false; + } + + if (string.IsNullOrWhiteSpace(SenderUsername)) + { + error = "SenderUsername must not be empty"; + return false; + } + + if (string.IsNullOrWhiteSpace(Text)) + { + error = "Text must not be empty"; + return false; + } + + error = null; + return true; + } +} diff --git a/ChatApplication/ChatApplication.Web/DTOs/MessageDTOs/SendMessageResponse.cs b/ChatApplication/ChatApplication.Web/DTOs/MessageDTOs/SendMessageResponse.cs new file mode 100644 index 0000000..e66d590 --- /dev/null +++ b/ChatApplication/ChatApplication.Web/DTOs/MessageDTOs/SendMessageResponse.cs @@ -0,0 +1,7 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatApplication.Web.Dtos; + +public record SendMessageResponse( + [Required] long CreatedUnixTime +); \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/DTOs/ProfileDTOs/Profile.cs b/ChatApplication/ChatApplication.Web/DTOs/ProfileDTOs/Profile.cs new file mode 100644 index 0000000..a702e96 --- /dev/null +++ b/ChatApplication/ChatApplication.Web/DTOs/ProfileDTOs/Profile.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatApplication.Web.Dtos; + +public record Profile( + [Required] string Username, + [Required] string FirstName, + [Required] string LastName, + string ProfilePictureId=""); \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Exceptions/ConversationExceptions/ConversationAlreadyExistsException.cs b/ChatApplication/ChatApplication.Web/Exceptions/ConversationExceptions/ConversationAlreadyExistsException.cs new file mode 100644 index 0000000..782a7b5 --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Exceptions/ConversationExceptions/ConversationAlreadyExistsException.cs @@ -0,0 +1,8 @@ +namespace ChatApplication.Exceptions; + +public class ConversationAlreadyExistsException:Exception +{ + public ConversationAlreadyExistsException(string message) : base(message){} + + +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Exceptions/ConversationExceptions/ConversationNotFoundException.cs b/ChatApplication/ChatApplication.Web/Exceptions/ConversationExceptions/ConversationNotFoundException.cs new file mode 100644 index 0000000..1209ee3 --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Exceptions/ConversationExceptions/ConversationNotFoundException.cs @@ -0,0 +1,9 @@ +namespace ChatApplication.Exceptions; + +public class ConversationNotFoundException : Exception +{ + + public ConversationNotFoundException(string message) : base(message) { } + + +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Exceptions/ConversationParticipantsExceptions/DuplicateParticipantException.cs b/ChatApplication/ChatApplication.Web/Exceptions/ConversationParticipantsExceptions/DuplicateParticipantException.cs new file mode 100644 index 0000000..e8a087b --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Exceptions/ConversationParticipantsExceptions/DuplicateParticipantException.cs @@ -0,0 +1,7 @@ +namespace ChatApplication.Exceptions.ConversationParticipantsExceptions; + +public class DuplicateParticipantException : Exception +{ + public DuplicateParticipantException(string message) : base(message) { } + +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Exceptions/ConversationParticipantsExceptions/SenderNotFoundException.cs b/ChatApplication/ChatApplication.Web/Exceptions/ConversationParticipantsExceptions/SenderNotFoundException.cs new file mode 100644 index 0000000..624e927 --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Exceptions/ConversationParticipantsExceptions/SenderNotFoundException.cs @@ -0,0 +1,7 @@ +namespace ChatApplication.Exceptions.ConversationParticipantsExceptions; + +public class SenderNotFoundException : Exception +{ + public SenderNotFoundException(string message) : base(message) { } + +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Exceptions/ImageExceptions/ImageNotFoundException.cs b/ChatApplication/ChatApplication.Web/Exceptions/ImageExceptions/ImageNotFoundException.cs new file mode 100644 index 0000000..504fa67 --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Exceptions/ImageExceptions/ImageNotFoundException.cs @@ -0,0 +1,12 @@ +namespace ChatApplication.Exceptions; + +public class ImageNotFoundException : Exception +{ + + public ImageNotFoundException(string message) : base(message) { } + + + + + +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Exceptions/MessageExceptions/MessageAlreadyExistsException.cs b/ChatApplication/ChatApplication.Web/Exceptions/MessageExceptions/MessageAlreadyExistsException.cs new file mode 100644 index 0000000..6b0bf05 --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Exceptions/MessageExceptions/MessageAlreadyExistsException.cs @@ -0,0 +1,11 @@ +namespace ChatApplication.Exceptions; + +public class MessageAlreadyExistsException : Exception +{ + + + public MessageAlreadyExistsException(string message) : base(message) { } + + + +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Exceptions/MessageExceptions/MessageNotFoundException.cs b/ChatApplication/ChatApplication.Web/Exceptions/MessageExceptions/MessageNotFoundException.cs new file mode 100644 index 0000000..20a200b --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Exceptions/MessageExceptions/MessageNotFoundException.cs @@ -0,0 +1,8 @@ +namespace ChatApplication.Exceptions; + +public class MessageNotFoundException : Exception +{ + + public MessageNotFoundException(string message) : base(message) {} + +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Exceptions/ProfileExceptions/ProfileAlreadyExistsException.cs b/ChatApplication/ChatApplication.Web/Exceptions/ProfileExceptions/ProfileAlreadyExistsException.cs new file mode 100644 index 0000000..afbdb54 --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Exceptions/ProfileExceptions/ProfileAlreadyExistsException.cs @@ -0,0 +1,9 @@ +namespace ChatApplication.Exceptions; + +public class ProfileAlreadyExistsException : Exception +{ + public ProfileAlreadyExistsException(string message) : base(message) + { + } + +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Exceptions/ProfileExceptions/ProfileNotFoundException.cs b/ChatApplication/ChatApplication.Web/Exceptions/ProfileExceptions/ProfileNotFoundException.cs new file mode 100644 index 0000000..20da36b --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Exceptions/ProfileExceptions/ProfileNotFoundException.cs @@ -0,0 +1,9 @@ +using ChatApplication.Web.Dtos; + +namespace ChatApplication.Exceptions; + +public class ProfileNotFoundException : Exception +{ + public ProfileNotFoundException(string message) : base(message) { } + +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Exceptions/StorageExceptions/StorageUnavailableException.cs b/ChatApplication/ChatApplication.Web/Exceptions/StorageExceptions/StorageUnavailableException.cs new file mode 100644 index 0000000..ee6f76f --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Exceptions/StorageExceptions/StorageUnavailableException.cs @@ -0,0 +1,12 @@ +namespace ChatApplication.Exceptions.StorageExceptions; + +public class StorageUnavailableException: Exception +{ + public StorageUnavailableException(string message) : base(message) + { + } + public StorageUnavailableException(string message, Exception e) : base(message, e) + { + } + +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Middleware/ExceptionMiddleware.cs b/ChatApplication/ChatApplication.Web/Middleware/ExceptionMiddleware.cs new file mode 100644 index 0000000..293d53f --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Middleware/ExceptionMiddleware.cs @@ -0,0 +1,59 @@ +using ChatApplication.Exceptions.StorageExceptions; +using Newtonsoft.Json; + +namespace ChatApplication.Middleware; + +public class ExceptionMiddleware +{ + private readonly RequestDelegate _next; + private readonly IWebHostEnvironment _webHostEnvironment; + + public ExceptionMiddleware(RequestDelegate next, IWebHostEnvironment webHostEnvironment) + { + _next = next; + _webHostEnvironment = webHostEnvironment; + } + + public async Task Invoke(HttpContext context) + { + try + { + await _next(context); + } + catch (Exception e) + { + if (context.Response.HasStarted) + { + throw; + } + + int statusCode = 500; + if (e is StorageUnavailableException) + { + statusCode = 503; + } + + context.Response.Clear(); + context.Response.StatusCode = statusCode; + context.Response.ContentType = "application/json"; + + var response = new + { + Message = e.Message, + Exception = SerializeException(e) + }; + + var body = JsonConvert.SerializeObject(response); + await context.Response.WriteAsync(body); + } + } + + private string? SerializeException(Exception e) + { + if (_webHostEnvironment.IsProduction()) + { + return null; + } + return e.ToString(); + } +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Program.cs b/ChatApplication/ChatApplication.Web/Program.cs new file mode 100644 index 0000000..0fa6b45 --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Program.cs @@ -0,0 +1,106 @@ +using System.Data; +using Azure.Messaging.ServiceBus; +using Azure.Storage.Blobs; +using ChatApplication.Configuration; +using ChatApplication.Middleware; +using ChatApplication.Serializers.Implementations; +using ChatApplication.ServiceBus; +using ChatApplication.ServiceBus.Interfaces; +using ChatApplication.Services; +using ChatApplication.Storage; +using ChatApplication.Storage.SQL; +using Microsoft.Azure.Cosmos; +using Microsoft.Extensions.Options; +using Microsoft.Data.SqlClient; + + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +builder.Services.AddControllers(); +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + + + +builder.Services.Configure(builder.Configuration.GetSection("Cosmos")); +builder.Services.Configure(builder.Configuration.GetSection("BlobStorage")); +builder.Services.Configure(builder.Configuration.GetSection("SQL")); +builder.Services.Configure(builder.Configuration.GetSection("ServiceBus")); + +builder.Services.AddSingleton(); + +builder.Services.AddSingleton(sp => +{ + var blobOptions = sp.GetRequiredService>(); + return new BlobContainerClient(blobOptions.Value.ConnectionString, blobOptions.Value.ContainerName); +}); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +//Comment the above and uncomment the below to use SQL + +// 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(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + + +builder.Services.AddSingleton(sp => +{ + var serviceBusOptions = sp.GetRequiredService>(); + return new ServiceBusClient(serviceBusOptions.Value.ConnectionString); +}); + + +builder.Services.AddSingleton(sp => +{ + var configuration = sp.GetRequiredService>(); + var connectionString = configuration.Value.ConnectionString; + return new SqlConnection(connectionString); +}); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); + + +builder.Services.AddApplicationInsightsTelemetry(); +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.UseMiddleware(); + + +app.Run(); + +public partial class Program { } \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Properties/launchSettings.json b/ChatApplication/ChatApplication.Web/Properties/launchSettings.json new file mode 100644 index 0000000..b1b03ff --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:41456", + "sslPort": 44376 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5229", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7073;http://localhost:5229", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/ChatApplication/ChatApplication.Web/Serializers/Implementations/JsonMessageSerializer.cs b/ChatApplication/ChatApplication.Web/Serializers/Implementations/JsonMessageSerializer.cs new file mode 100644 index 0000000..16fbbed --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Serializers/Implementations/JsonMessageSerializer.cs @@ -0,0 +1,19 @@ +using ChatApplication.Web.Dtos; +using Newtonsoft.Json; + +namespace ChatApplication.Serializers.Implementations; + +public class JsonMessageSerializer : IMessageSerializer +{ + + public string SerializeMessage(Message message) + { + return JsonConvert.SerializeObject(message); + } + + + public Message DeserializeMessage(string serializedMessage) + { + return JsonConvert.DeserializeObject (serializedMessage); + } +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Serializers/Implementations/JsonStartConversationParametersSerializer.cs b/ChatApplication/ChatApplication.Web/Serializers/Implementations/JsonStartConversationParametersSerializer.cs new file mode 100644 index 0000000..943468e --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Serializers/Implementations/JsonStartConversationParametersSerializer.cs @@ -0,0 +1,19 @@ +using ChatApplication.Web.Dtos; +using Newtonsoft.Json; + +namespace ChatApplication.Serializers.Implementations; + + +public class JsonStartConversationParametersSerializer : IStartConversationParametersSerializer +{ + + public string SerializeStartConversationParametersSerializer(StartConversationParameters parameters) + { + return JsonConvert.SerializeObject(parameters); + } + + public StartConversationParameters DeserializeStartConversationParameters(string serializedStartConversationParameters) + { + return JsonConvert.DeserializeObject(serializedStartConversationParameters); + } +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Serializers/Interfaces/IMessageSerializer.cs b/ChatApplication/ChatApplication.Web/Serializers/Interfaces/IMessageSerializer.cs new file mode 100644 index 0000000..799f4f7 --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Serializers/Interfaces/IMessageSerializer.cs @@ -0,0 +1,14 @@ +using ChatApplication.Web.Dtos; + +namespace ChatApplication.Serializers.Implementations; + +public interface IMessageSerializer +{ + /// + /// string + string SerializeMessage(Message message); + + /// + /// Message + Message DeserializeMessage(string serializedMessage); +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Serializers/Interfaces/IStartConversationParametersSerializer.cs b/ChatApplication/ChatApplication.Web/Serializers/Interfaces/IStartConversationParametersSerializer.cs new file mode 100644 index 0000000..d126f77 --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Serializers/Interfaces/IStartConversationParametersSerializer.cs @@ -0,0 +1,20 @@ +using ChatApplication.Web.Dtos; + +namespace ChatApplication.Serializers.Implementations; + +public interface IStartConversationParametersSerializer +{ + /// + /// Serialize StartConversationParameters. + /// + /// + /// string + string SerializeStartConversationParametersSerializer(StartConversationParameters parameters); + + /// + /// Deserialize StartConversationParameters. + /// + /// + /// StartConversationParameters + StartConversationParameters DeserializeStartConversationParameters(string serializedStartConversationParameters); +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/ServiceBus/Implementations/AddMessageServiceBusPublisher.cs b/ChatApplication/ChatApplication.Web/ServiceBus/Implementations/AddMessageServiceBusPublisher.cs new file mode 100644 index 0000000..50eae1d --- /dev/null +++ b/ChatApplication/ChatApplication.Web/ServiceBus/Implementations/AddMessageServiceBusPublisher.cs @@ -0,0 +1,26 @@ +using ChatApplication.ServiceBus.Interfaces; +using ChatApplication.Web.Dtos; +using Azure.Messaging.ServiceBus; +using ChatApplication.Configuration; +using ChatApplication.Serializers.Implementations; +using Microsoft.Extensions.Options; + +namespace ChatApplication.ServiceBus; + +public class AddMessageServiceBusPublisher : IAddMessageServiceBusPublisher +{ + private readonly ServiceBusSender _sender; + private readonly IMessageSerializer _messageSerializer; + + public AddMessageServiceBusPublisher(ServiceBusClient serviceBusClient, IMessageSerializer messageSerializer, IOptions options) + { + _sender = serviceBusClient.CreateSender(options.Value.AddMessageQueueName); + _messageSerializer = messageSerializer; + } + + public Task Send(Message message) + { + var serializedMessage = _messageSerializer.SerializeMessage(message); + return _sender.SendMessageAsync(new ServiceBusMessage(serializedMessage)); + } +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/ServiceBus/Implementations/StartConversationServiceBusPublisher.cs b/ChatApplication/ChatApplication.Web/ServiceBus/Implementations/StartConversationServiceBusPublisher.cs new file mode 100644 index 0000000..d9e4a52 --- /dev/null +++ b/ChatApplication/ChatApplication.Web/ServiceBus/Implementations/StartConversationServiceBusPublisher.cs @@ -0,0 +1,28 @@ +using Azure.Messaging.ServiceBus; +using ChatApplication.Configuration; +using ChatApplication.Serializers.Implementations; +using ChatApplication.ServiceBus.Interfaces; +using ChatApplication.Web.Dtos; +using Microsoft.Extensions.Options; + +namespace ChatApplication.ServiceBus; + +public class StartConversationServiceBusPublisher : IStartConversationServiceBusPublisher +{ + private readonly ServiceBusSender _sender; + private readonly IStartConversationParametersSerializer _serializer; + + public StartConversationServiceBusPublisher(ServiceBusClient serviceBusClient, IStartConversationParametersSerializer profileSerializer, + IOptions options + ) + { + _sender = serviceBusClient.CreateSender(options.Value.StartConversationQueueName); + _serializer = profileSerializer; + } + + public async Task Send(StartConversationParameters startConversationParameters) + { + var serializedParameters = _serializer.SerializeStartConversationParametersSerializer(startConversationParameters); + await _sender.SendMessageAsync(new ServiceBusMessage(serializedParameters)); + } +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/ServiceBus/Interfaces/IAddMessageServiceBusPublisher.cs b/ChatApplication/ChatApplication.Web/ServiceBus/Interfaces/IAddMessageServiceBusPublisher.cs new file mode 100644 index 0000000..cf4656b --- /dev/null +++ b/ChatApplication/ChatApplication.Web/ServiceBus/Interfaces/IAddMessageServiceBusPublisher.cs @@ -0,0 +1,13 @@ +using ChatApplication.Web.Dtos; + +namespace ChatApplication.ServiceBus.Interfaces; + +public interface IAddMessageServiceBusPublisher +{ + /// + /// Publish message to service bus. + /// + /// + /// Task + public Task Send(Message message); +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/ServiceBus/Interfaces/IStartConversationServiceBusPublisher.cs b/ChatApplication/ChatApplication.Web/ServiceBus/Interfaces/IStartConversationServiceBusPublisher.cs new file mode 100644 index 0000000..74e29ea --- /dev/null +++ b/ChatApplication/ChatApplication.Web/ServiceBus/Interfaces/IStartConversationServiceBusPublisher.cs @@ -0,0 +1,13 @@ +using ChatApplication.Web.Dtos; + +namespace ChatApplication.ServiceBus.Interfaces; + +public interface IStartConversationServiceBusPublisher +{ + /// + /// Publish StartConversationParameters to service bus. + /// + /// + /// Task + public Task Send(StartConversationParameters parameters); +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Services/AddMessageHostedService.cs b/ChatApplication/ChatApplication.Web/Services/AddMessageHostedService.cs new file mode 100644 index 0000000..76194c9 --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Services/AddMessageHostedService.cs @@ -0,0 +1,50 @@ +using Azure.Messaging.ServiceBus; +using ChatApplication.Configuration; +using ChatApplication.Serializers.Implementations; +using Microsoft.Extensions.Options; + +namespace ChatApplication.Services; + +public class AddMessageHostedService : IHostedService +{ + + private readonly IMessageSerializer _messageSerializer; + private readonly IConversationService _conversationService; + private readonly ServiceBusProcessor _addMessageProcessor; + + public AddMessageHostedService(ServiceBusClient serviceBusClient, IMessageSerializer messageSerializer, IConversationService conversationService, IOptions options) + { + _messageSerializer = messageSerializer; + _conversationService = conversationService; + _addMessageProcessor = serviceBusClient.CreateProcessor(options.Value.AddMessageQueueName); + + _addMessageProcessor.ProcessMessageAsync += MessageHandler; + _addMessageProcessor.ProcessErrorAsync += ErrorHandler; + } + public Task StartAsync(CancellationToken cancellationToken) + { + return _addMessageProcessor.StartProcessingAsync(cancellationToken); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return _addMessageProcessor.StopProcessingAsync(cancellationToken); + } + + private async Task MessageHandler(ProcessMessageEventArgs args) + { + string data = args.Message.Body.ToString(); + Console.WriteLine($"Received: {data}"); + + var message = _messageSerializer.DeserializeMessage(data); + await _conversationService.AddMessage(message); + + await args.CompleteMessageAsync(args.Message); + } + + private Task ErrorHandler(ProcessErrorEventArgs args) + { + Console.WriteLine(args.Exception.ToString()); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Services/ServicesImplementations/ConversationService.cs b/ChatApplication/ChatApplication.Web/Services/ServicesImplementations/ConversationService.cs new file mode 100644 index 0000000..9f3da80 --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Services/ServicesImplementations/ConversationService.cs @@ -0,0 +1,148 @@ +using ChatApplication.Exceptions; +using ChatApplication.Exceptions.ConversationParticipantsExceptions; +using ChatApplication.ServiceBus.Interfaces; +using ChatApplication.Storage; +using ChatApplication.Utils; +using ChatApplication.Web.Dtos; +using Microsoft.AspNetCore.Http.HttpResults; +using Newtonsoft.Json; + +namespace ChatApplication.Services; + +public class ConversationService : IConversationService +{ + private readonly IMessageStore _messageStore; + private readonly IConversationStore _conversationStore; + private readonly IProfileStore _profileStore; + private readonly IAddMessageServiceBusPublisher _addMessageServiceBusPublisher; + private readonly IStartConversationServiceBusPublisher _startConversationServiceBusPublisher; + public ConversationService(IMessageStore messageStore, IConversationStore conversationStore, IProfileStore profileStore, + IAddMessageServiceBusPublisher addMessageServiceBusPublisher, IStartConversationServiceBusPublisher startConversationServiceBusPublisher) + { + _messageStore = messageStore; + _conversationStore = conversationStore; + _profileStore = profileStore; + _addMessageServiceBusPublisher = addMessageServiceBusPublisher; + _startConversationServiceBusPublisher = startConversationServiceBusPublisher; + } + + public async Task EnqueueAddMessage(Message message) + { + await _conversationStore.GetUserConversation(message.SenderUsername, message.ConversationId); + + try + { + await _messageStore.GetMessage(message.ConversationId, message.MessageId); + } + catch (MessageNotFoundException) + { + await _addMessageServiceBusPublisher.Send(message); + return; + } + + throw new MessageAlreadyExistsException($"Message with id {message.MessageId} already exists"); + } + + public async Task EnqueueStartConversation(StartConversationParameters parameters) + { + + CheckIfValidParticipants(parameters.participants, parameters.senderUsername); + await Task.WhenAll(parameters.participants.Select(participant => _profileStore.GetProfile(participant))); + + var id = GenerateConversationId(parameters.participants); + try + { + await _messageStore.GetMessage(id, parameters.messageId); + } + catch (MessageNotFoundException) + { + await _startConversationServiceBusPublisher.Send(parameters); + return id; + } + + throw new MessageAlreadyExistsException($"Message with id {parameters.messageId} already exists"); + + + } + + public async Task AddMessage(Message message) + { + var senderConversation = await _conversationStore.GetUserConversation(message.SenderUsername, message.ConversationId); + await _conversationStore.UpdateConversationLastMessageTime(senderConversation, message.CreatedUnixTime); + try + { + await _messageStore.AddMessage(message); + } + catch (MessageAlreadyExistsException) + { + return; + } + + } + + public async Task StartConversation(StartConversationParameters parameters) + { + var id = GenerateConversationId(parameters.participants); + var participantsProfile = + await Task.WhenAll(parameters.participants.Select(participant => _profileStore.GetProfile(participant))); + + var userConversations = parameters.participants.Select(participantUsername => + { + var recipients = new List(participantsProfile); + recipients.Remove(Array.Find(participantsProfile, x => x.Username == participantUsername)); + return new UserConversation(id, recipients, parameters.createdTime, participantUsername); + }).ToList(); + + try + { + await Task.WhenAll(userConversations.Select(conversation => + _conversationStore.CreateUserConversation(conversation))); + } + catch (ConversationAlreadyExistsException) + { + } + + var message = new Message(parameters.messageId, parameters.senderUsername, id, parameters.messageContent, parameters.createdTime); + + try + { + await _messageStore.AddMessage(message); + } + catch (MessageAlreadyExistsException) + { + } + + return id; + } + + public async Task GetMessages(GetMessagesParameters parameters) + { + return await _messageStore.GetMessages(parameters); + } + + public async Task GetConversations(GetConversationsParameters parameters) + { + return await _conversationStore.GetConversations(parameters); + } + + private static void CheckIfValidParticipants(IReadOnlyCollection participants, string senderUsername) + { + var foundSenderUsername = participants.Aggregate(false, (current, participant) => current || participant == senderUsername); + if (!foundSenderUsername) + { + throw new SenderNotFoundException($"Sender username {senderUsername} not found in participants"); + } + var duplicates = participants.GroupBy(p => p).Where(g => g.Count() > 1).Select(g => g.Key).ToList(); + if (duplicates.Any()) + { + throw new DuplicateParticipantException($"Participant(s) {string.Join(", ", duplicates)} is/are duplicated"); + } + } + + private static string GenerateConversationId(List participants) + { + participants.Sort(); + return participants.Aggregate("", (current, participant) => current + ("_" + participant)); + } + +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Services/ServicesImplementations/ImageService.cs b/ChatApplication/ChatApplication.Web/Services/ServicesImplementations/ImageService.cs new file mode 100644 index 0000000..754bcde --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Services/ServicesImplementations/ImageService.cs @@ -0,0 +1,24 @@ +using ChatApplication.Storage; +using ChatApplication.Utils; + +namespace ChatApplication.Services; + +public class ImageService : IImageService +{ + private readonly IImageStore _imageStore; + public ImageService(IImageStore imageStore, ILogger logger) + { + _imageStore = imageStore; + } + public async Task AddImage(MemoryStream data, string contentType) + { + var id = Guid.NewGuid().ToString(); + await _imageStore.AddImage(id, data, contentType); + return id; + } + + public async Task GetImage(string id) + { + return await _imageStore.GetImage(id); + } +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Services/ServicesImplementations/ProfileService.cs b/ChatApplication/ChatApplication.Web/Services/ServicesImplementations/ProfileService.cs new file mode 100644 index 0000000..499e08a --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Services/ServicesImplementations/ProfileService.cs @@ -0,0 +1,28 @@ +using ChatApplication.Storage; +using ChatApplication.Web.Dtos; + +namespace ChatApplication.Services; + +public class ProfileService : IProfileService +{ + private readonly IProfileStore _profileStore; + private readonly IImageStore _imageStore; + + public ProfileService(IProfileStore profileStore, IImageStore imageStore) + { + _profileStore = profileStore; + _imageStore = imageStore; + } + + + public async Task AddProfile(Profile profile) + { + await _profileStore.AddProfile(profile); + } + + public async Task GetProfile(string username) + { + var profile = await _profileStore.GetProfile(username); + return profile; + } +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Services/ServicesInterfaces/IConversationService.cs b/ChatApplication/ChatApplication.Web/Services/ServicesInterfaces/IConversationService.cs new file mode 100644 index 0000000..7fc5f9b --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Services/ServicesInterfaces/IConversationService.cs @@ -0,0 +1,62 @@ +using ChatApplication.Web.Dtos; + +namespace ChatApplication.Services; + +public interface IConversationService +{ + /// + /// Add Message + /// + /// + /// Task + /// ConversationNotFoundException is thrown if conversation does not exist

+ /// StorageUnavailableException is thrown if storage layer cannot be reached

+ public Task AddMessage(Message message); + + /// + /// Get all messages in a conversation + /// + /// + /// Task with GetMessagesResult + /// StorageUnavailableException is thrown if storage layer cannot be reached

+ public Task GetMessages(GetMessagesParameters parameters); + + /// + /// Start a conversation + /// + /// + /// Task with a string + /// StorageUnavailableException is thrown if storage layer cannot be reached

+ public Task StartConversation(StartConversationParameters parameters); + + /// + /// Get All conversations of a User + /// + /// + /// Task with GetConversationsResult + /// StorageUnavailableException is thrown if storage layer cannot be reached

+ public Task GetConversations(GetConversationsParameters parameters); + + /// + /// Enqueue message to the message bus + /// + /// + /// Task + /// StorageUnavailableException is thrown if storage layer cannot be reached

+ /// MessageAlreadyExistsException is thrown if message already exists

+ /// ConversationNotFoundException is thrown if conversation does not exist

+ public Task EnqueueAddMessage(Message message); + + /// + /// Enqueue start conversation to the message bus + /// + /// + /// Task with string + /// StorageUnavailableException is thrown if storage layer cannot be reached

+ /// ConversationAlreadyExistsException is thrown if conversation already exists

+ /// ProfileNotFoundException is thrown if profile does not exist

+ /// SenderNotFoundException is thrown if sender does not exist

+ /// DuplicateParticipantException is thrown if participant already exists

+ public Task EnqueueStartConversation(StartConversationParameters parameters); + +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Services/ServicesInterfaces/IImageService.cs b/ChatApplication/ChatApplication.Web/Services/ServicesInterfaces/IImageService.cs new file mode 100644 index 0000000..6cf87be --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Services/ServicesInterfaces/IImageService.cs @@ -0,0 +1,25 @@ +using ChatApplication.Utils; +namespace ChatApplication.Services; + +public interface IImageService +{ + /// + /// Add an image to the blob storage + /// + /// + /// + /// Task with string + /// ArgumentException if data is null or empty

+ /// StorageUnavailableException if storage layer cannot be reached

+ Task AddImage(MemoryStream data, string contentType); + + /// + /// Get an existing image + /// + /// + /// Task with Image? + /// ArgumentException if id is null or empty

+ /// ImageNotFoundException if no image is found for the given id

+ /// StorageUnavailableException if storage layer cannot be reached

+ Task GetImage(string id); +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Services/ServicesInterfaces/IProfileService.cs b/ChatApplication/ChatApplication.Web/Services/ServicesInterfaces/IProfileService.cs new file mode 100644 index 0000000..7468e14 --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Services/ServicesInterfaces/IProfileService.cs @@ -0,0 +1,25 @@ +using ChatApplication.Web.Dtos; + +namespace ChatApplication.Services; + +public interface IProfileService +{ + /// + /// Add a profile + /// + /// + /// Task + /// StorageUnavailableException is thrown if storage layer cannot be reached

+ /// ProfileAlreadyExistsException is thrown if profile already exists

+ /// ArgumentException is thrown if profile is null

+ Task AddProfile(Profile profile); + + /// + /// Get a profile + /// + /// + /// Task with a profile + /// StorageUnavailableException is thrown if storage layer cannot be reached

+ /// ProfileNotFoundException is thrown if profile does not exist

+ Task GetProfile(string username); +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Services/StartConversationHostedService.cs b/ChatApplication/ChatApplication.Web/Services/StartConversationHostedService.cs new file mode 100644 index 0000000..9ec93b1 --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Services/StartConversationHostedService.cs @@ -0,0 +1,51 @@ +using Azure.Messaging.ServiceBus; +using ChatApplication.Configuration; +using ChatApplication.Serializers.Implementations; +using Microsoft.Extensions.Options; + +namespace ChatApplication.Services; + +public class StartConversationHostedService : IHostedService +{ + private readonly IConversationService _conversationService; + private readonly IStartConversationParametersSerializer _startConversationSerializer; + private readonly ServiceBusProcessor _startConversationServiceBusProcessor; + + public StartConversationHostedService(IConversationService conversationService, IStartConversationParametersSerializer startConversationSerializer, ServiceBusClient serviceBusClient, IOptions options) + { + _conversationService = conversationService; + _startConversationSerializer = startConversationSerializer; + _startConversationServiceBusProcessor = serviceBusClient.CreateProcessor(options.Value.StartConversationQueueName); + + // add handler to process messages + _startConversationServiceBusProcessor.ProcessMessageAsync += MessageHandler; + + // add handler to process any errors + _startConversationServiceBusProcessor.ProcessErrorAsync += ErrorHandler; + } + public Task StartAsync(CancellationToken cancellationToken) + { + return _startConversationServiceBusProcessor.StartProcessingAsync(cancellationToken); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return _startConversationServiceBusProcessor.StopProcessingAsync(cancellationToken); + } + + private async Task MessageHandler(ProcessMessageEventArgs args) + { + string data = args.Message.Body.ToString(); + + var startConversationParameters = _startConversationSerializer.DeserializeStartConversationParameters(data); + await _conversationService.StartConversation(startConversationParameters); + + await args.CompleteMessageAsync(args.Message); + } + + private Task ErrorHandler(ProcessErrorEventArgs args) + { + Console.WriteLine(args.Exception.ToString()); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Storage/Entities/ConversationEntity.cs b/ChatApplication/ChatApplication.Web/Storage/Entities/ConversationEntity.cs new file mode 100644 index 0000000..e1570de --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Storage/Entities/ConversationEntity.cs @@ -0,0 +1,13 @@ +using ChatApplication.Web.Dtos; + +namespace ChatApplication.Storage.Entities; + +public record ConversationEntity +{ + public string partitionKey { get; init;} + public string id { get; init;} + public List Participants { get; set; } + public long lastMessageTime { get; set; } + +} + \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Storage/Entities/MessageEntity.cs b/ChatApplication/ChatApplication.Web/Storage/Entities/MessageEntity.cs new file mode 100644 index 0000000..05a47ed --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Storage/Entities/MessageEntity.cs @@ -0,0 +1,9 @@ +namespace ChatApplication.Storage.Entities; + +public record MessageEntity( + string partitionKey, + string id, + string SenderUsername, + long CreatedUnixTime, + string MessageContent + ); \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Storage/Entities/ProfileEntity.cs b/ChatApplication/ChatApplication.Web/Storage/Entities/ProfileEntity.cs new file mode 100644 index 0000000..7242e68 --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Storage/Entities/ProfileEntity.cs @@ -0,0 +1,3 @@ +namespace ChatApplication.Storage.Entities; + +public record ProfileEntity(string partitionKey, string id, string firstName, string lastName, string ProfilePictureId); \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Storage/Models/ConversationModel.cs b/ChatApplication/ChatApplication.Web/Storage/Models/ConversationModel.cs new file mode 100644 index 0000000..2692c20 --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Storage/Models/ConversationModel.cs @@ -0,0 +1,3 @@ +namespace ChatApplication.Storage.Models; + +public record ConversationModel (string ConversationId, long ModifiedUnixTime); \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Storage/Models/ConversationParticipantModel.cs b/ChatApplication/ChatApplication.Web/Storage/Models/ConversationParticipantModel.cs new file mode 100644 index 0000000..90bc142 --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Storage/Models/ConversationParticipantModel.cs @@ -0,0 +1,3 @@ +namespace ChatApplication.Storage.Models; + +public record ConversationParticipantsModel(string Username, string ConversationId); diff --git a/ChatApplication/ChatApplication.Web/Storage/StorageInterfaces/IConversationStore.cs b/ChatApplication/ChatApplication.Web/Storage/StorageInterfaces/IConversationStore.cs new file mode 100644 index 0000000..6c177dc --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Storage/StorageInterfaces/IConversationStore.cs @@ -0,0 +1,42 @@ +using ChatApplication.Web.Dtos; + +namespace ChatApplication.Storage; + +public interface IConversationStore +{ + + /// + /// + /// Task with UserConversation + /// ConversationNotFoundException is thrown if conversation does not exist

+ /// StorageUnavailableException is thrown if storage layer cannot be reached

+ public Task GetUserConversation(string username, string conversationId); + + /// + /// + /// Task + /// StorageUnavailableException is thrown if storage cannot be reached

+ public Task UpdateConversationLastMessageTime(UserConversation senderConversation, long lastMessageTime); + + /// + /// Task + /// StorageUnavailableException is thrown if storage cannot be reached

+ /// ConversationAlreadyExistsException is thrown if conversation already exists

+ public Task CreateUserConversation(UserConversation userConversation); + + /// + /// Deletes a User Conversation + /// + /// + /// Task + /// StorageUnavailableException is thrown if storage cannot be reached

+ public Task DeleteUserConversation(UserConversation userConversation); + + /// + /// Get all of a User's conversations + /// + /// + /// Task with GetConversationsResult + /// StorageUnavailableException is thrown if storage cannot be reached

+ public Task GetConversations(GetConversationsParameters parameters); +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Storage/StorageInterfaces/IImageStore.cs b/ChatApplication/ChatApplication.Web/Storage/StorageInterfaces/IImageStore.cs new file mode 100644 index 0000000..7b37a42 --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Storage/StorageInterfaces/IImageStore.cs @@ -0,0 +1,36 @@ +using ChatApplication.Utils; +namespace ChatApplication.Storage; + +public interface IImageStore +{ + /// + /// Adds an image to the blob storage + /// + /// + /// + /// + /// + /// ArgumentException is thrown if blobName is null or empty

+ /// StorageUnavailableException is thrown if storage layer cannot be reached

+ Task AddImage(string blobName, MemoryStream data, string contentType); + + /// + /// Gets an image from the blob storage + /// + /// + /// ImageUtil + /// ArgumentException if id is null or empty



+ ///
+ /// ImageNotFoundException if no image is found for the given id

+ /// StorageUnavailableException if storage layer cannot be reached

+ Task GetImage(string id); + + /// + /// Delete an existing image + /// + /// + /// Task + /// ArgumentException if id is null or empty

+ /// StorageUnavailableException if storage layer cannot be reached

+ Task DeleteImage(string id); +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Storage/StorageInterfaces/IMessageStore.cs b/ChatApplication/ChatApplication.Web/Storage/StorageInterfaces/IMessageStore.cs new file mode 100644 index 0000000..4f1e343 --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Storage/StorageInterfaces/IMessageStore.cs @@ -0,0 +1,41 @@ +using ChatApplication.Web.Dtos; + +namespace ChatApplication.Storage; + +public interface IMessageStore +{ + /// + /// + /// + /// + /// + /// ConversationNotFoundException if conversationId is not found



+ /// MessageAlreadyExistsException if messageId already exists + Task AddMessage(Message message); + + /// + /// Get all messages in a conversation + /// + /// + /// Task with GetMessagesResult + /// StorageUnavailableException is thrown if storage cannot be reached

+ Task GetMessages(GetMessagesParameters parameters); + + /// + /// Delete a message + /// + /// + /// Task + /// StorageUnavailableException is thrown if storage cannot be reached

+ Task DeleteMessage(Message message); + + /// + /// + /// + /// + /// + /// Task with a Message + /// StorageUnavailableException is thrown if storage cannot be reached

+ /// MessageNotFoundException is thrown if message is not found

+ Task GetMessage(string conversationId, string messageId); +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Storage/StorageInterfaces/IProfileStore.cs b/ChatApplication/ChatApplication.Web/Storage/StorageInterfaces/IProfileStore.cs new file mode 100644 index 0000000..6dfce6e --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Storage/StorageInterfaces/IProfileStore.cs @@ -0,0 +1,33 @@ +using ChatApplication.Web.Dtos; +namespace ChatApplication.Storage; + +public interface IProfileStore +{ + /// + /// + /// + /// + /// Task + /// ArgumentException if profile is null or any of the fields are null or empty, or any field is missing

+ /// ProfileAlreadyExistsException if a profile with the same username already exists

+ /// StorageUnavailableException if storage layer cannot be reached

+ /// + Task AddProfile(Profile profile); + + /// + /// + /// + /// + /// Task with Profile + /// ProfileNotFoundException if profile is not found

+ /// StorageUnavailableException if storage layer cannot be reached + Task GetProfile(string username); + + /// + /// Delete a profile + /// + /// + /// Task + /// StorageUnavailableException if storage layer cannot be reached

+ Task DeleteProfile(string username); +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Storage/StoragesImplementations/BlobImageStore.cs b/ChatApplication/ChatApplication.Web/Storage/StoragesImplementations/BlobImageStore.cs new file mode 100644 index 0000000..18b8570 --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Storage/StoragesImplementations/BlobImageStore.cs @@ -0,0 +1,94 @@ +using Azure; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using ChatApplication.Exceptions; +using ChatApplication.Exceptions.StorageExceptions; +using ChatApplication.Utils; + +namespace ChatApplication.Storage; + +public class BlobImageStore : IImageStore +{ + private readonly BlobContainerClient _blobContainerClient; + + public BlobImageStore(BlobContainerClient blobContainerClientClient) + { + _blobContainerClient = blobContainerClientClient; + } + + public async Task AddImage(string blobName, MemoryStream data, string contentType) + { + if (data == null || data.Length == 0) + throw new ArgumentException("Data is empty", nameof(data)); + + BlobClient blobClient = _blobContainerClient.GetBlobClient(blobName); + BlobHttpHeaders headers = new BlobHttpHeaders + { + ContentType = contentType + }; + data.Position = 0; + + try + { + await blobClient.UploadAsync(data, headers); + } + catch (RequestFailedException e) + { + throw new StorageUnavailableException($"Could not upload image {blobName}", e); + } + + } + + public async Task GetImage(string id) + { + if (string.IsNullOrWhiteSpace(id)) + { + throw new ArgumentException("Invalid id", nameof(id)); + } + + try + { + + var blobClient = _blobContainerClient.GetBlobClient(id); + + if (!await blobClient.ExistsAsync()) + throw new ImageNotFoundException($"No image found for {id}"); + + BlobProperties properties = await blobClient.GetPropertiesAsync(); + var response = await blobClient.DownloadAsync(); + await using var memoryStream = new MemoryStream(); + await response.Value.Content.CopyToAsync(memoryStream); + var bytes = memoryStream.ToArray(); + + return new Image(bytes, properties.ContentType); + } + catch (RequestFailedException e) + { + throw new StorageUnavailableException($"Could not download image {id}", e); + } + + } + + public async Task DeleteImage(string id) + { + if (string.IsNullOrWhiteSpace(id)) + { + throw new ArgumentException("Invalid id", nameof(id)); + } + + try + { + var blobClient = _blobContainerClient.GetBlobClient(id); + if (!await blobClient.ExistsAsync()) + { + return; + } + await blobClient.DeleteAsync(); + } + catch (RequestFailedException e) + { + throw new StorageUnavailableException($"Could not delete image {id}", e); + } + } +} + diff --git a/ChatApplication/ChatApplication.Web/Storage/StoragesImplementations/Cosmos/CosmosConversationStore.cs b/ChatApplication/ChatApplication.Web/Storage/StoragesImplementations/Cosmos/CosmosConversationStore.cs new file mode 100644 index 0000000..bd601ed --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Storage/StoragesImplementations/Cosmos/CosmosConversationStore.cs @@ -0,0 +1,188 @@ +using System.Net; +using ChatApplication.Exceptions; +using ChatApplication.Exceptions.StorageExceptions; +using ChatApplication.Storage.Entities; +using ChatApplication.Web.Dtos; +using Microsoft.Azure.Cosmos; +using Microsoft.Azure.Cosmos.Linq; + +namespace ChatApplication.Storage; + +public class CosmosConversationStore : IConversationStore +{ + private readonly CosmosClient _cosmosClient; + private Container ConversationContainer => _cosmosClient.GetDatabase("MainDatabase").GetContainer("Conversations"); + + public CosmosConversationStore(CosmosClient cosmosClient) + { + _cosmosClient = cosmosClient; + } + + public async Task GetUserConversation(string username, string conversationId) + { + try + { + var conversation = await ConversationContainer.ReadItemAsync(conversationId, + new PartitionKey(username) , + new ItemRequestOptions + { + ConsistencyLevel = ConsistencyLevel.Session + }); + return ToConversation(conversation.Resource); + } + catch(CosmosException e) + { + if (e.StatusCode == HttpStatusCode.NotFound) + { + throw new ConversationNotFoundException($"Could not resolve conversation with id : {conversationId}"); + } + + if (e.StatusCode != HttpStatusCode.BadRequest) + { + throw new StorageUnavailableException($"Could not get conversation with id : {conversationId} for user {username}", e); + } + + throw; + } + } + + private async Task UpdateConversationUserLastMessageTime(UserConversation userConversation, long lastMessageTime) + { + userConversation.LastMessageTime = lastMessageTime; + var entity = ToEntity(userConversation); + + try + { + await ConversationContainer.ReplaceItemAsync(entity, entity.id, + new PartitionKey(entity.partitionKey)); + } + catch (CosmosException e) + { + if (e.StatusCode != HttpStatusCode.BadRequest) + { + throw new StorageUnavailableException($"Could not update conversation with id {userConversation.ConversationId}'s last message time", e); + } + + throw; + } + } + + public async Task UpdateConversationLastMessageTime(UserConversation senderConversation, long lastMessageTime) + { + var participantsUsernames = new List {senderConversation.Username}; + var conversationId = senderConversation.ConversationId; + participantsUsernames.AddRange(senderConversation.Recipients.Select(x => x.Username)); + + var participantsConversation = await Task.WhenAll(participantsUsernames.Select(username => + GetUserConversation(username, conversationId))); + + await Task.WhenAll(participantsConversation.Select(conversation=> UpdateConversationUserLastMessageTime(conversation, lastMessageTime))); + } + + public async Task CreateUserConversation(UserConversation userConversation) + { + var entity = ToEntity(userConversation); + + try + { + await ConversationContainer.CreateItemAsync(entity); + } + catch (CosmosException e) + { + if (e.StatusCode == HttpStatusCode.Conflict) + { + throw new ConversationAlreadyExistsException( + $"Conversation with id :{userConversation.ConversationId} already exists"); + } + if (e.StatusCode != HttpStatusCode.BadRequest) + { + throw new StorageUnavailableException($"Could not create conversation with id : {userConversation.ConversationId} for user {userConversation.Username}", e); + } + + throw; + } + + } + + public async Task DeleteUserConversation(UserConversation userConversation) + { + try + { + await ConversationContainer.DeleteItemAsync( + id: userConversation.ConversationId, + partitionKey: new PartitionKey(userConversation.Username) + ); + } + catch (CosmosException e) + { + if (e.StatusCode == HttpStatusCode.NotFound) + { + return; + } + if (e.StatusCode != HttpStatusCode.BadRequest) + { + throw new StorageUnavailableException($"Could not delete conversation with id : {userConversation.ConversationId} for user {userConversation.Username}", e); + } + + throw; + } + } + + public async Task GetConversations(GetConversationsParameters parameters) + { + var options = new QueryRequestOptions + { + MaxItemCount = int.Min(int.Max(parameters.Limit,1), 100) + }; + + try + { + var query = GetFilteredConversations(parameters.Username, parameters.LastSeenConversationTime, parameters.ContinuationToken, options); + + using var iterator = query.ToFeedIterator(); + var response = await iterator.ReadNextAsync(); + var receivedConversations = response.Select(ToConversation).ToList(); + var newContinuationToken = response.ContinuationToken; + return new GetConversationsResult(receivedConversations, newContinuationToken); + } + catch (CosmosException e) + { + if (e.StatusCode != HttpStatusCode.BadRequest) + { + throw new StorageUnavailableException($"Could not get conversations for user {parameters.Username}", e); + } + + throw; + } + } + + + private static ConversationEntity ToEntity(UserConversation userConversation) + { + return new ConversationEntity + { + partitionKey = userConversation.Username, + id = userConversation.ConversationId, + Participants = userConversation.Recipients, + lastMessageTime = userConversation.LastMessageTime, + }; + } + + private static UserConversation ToConversation(ConversationEntity conversationEntity) + { + return new UserConversation( + conversationEntity.id, + conversationEntity.Participants, + conversationEntity.lastMessageTime, + conversationEntity.partitionKey + ); + } + + private IQueryable GetFilteredConversations(string username, long lastSeenConversationTime, string? continuationToken, QueryRequestOptions options) + { + return ConversationContainer.GetItemLinqQueryable(true, string.IsNullOrEmpty(continuationToken) ? null : continuationToken, options) + .Where(m => m.partitionKey == username && + m.lastMessageTime > lastSeenConversationTime) + .OrderByDescending(m => m.lastMessageTime); + } +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Storage/StoragesImplementations/Cosmos/CosmosMessageStore.cs b/ChatApplication/ChatApplication.Web/Storage/StoragesImplementations/Cosmos/CosmosMessageStore.cs new file mode 100644 index 0000000..c9eb42f --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Storage/StoragesImplementations/Cosmos/CosmosMessageStore.cs @@ -0,0 +1,152 @@ +using System.Net; +using ChatApplication.Exceptions; +using ChatApplication.Exceptions.StorageExceptions; +using ChatApplication.Storage.Entities; +using ChatApplication.Web.Dtos; +using Microsoft.Azure.Cosmos; +using Microsoft.Azure.Cosmos.Linq; + +namespace ChatApplication.Storage; + +public class CosmosMessageStore : IMessageStore +{ + private readonly CosmosClient _cosmosClient; + + public CosmosMessageStore(CosmosClient cosmosClient) + { + _cosmosClient = cosmosClient; + } + + private Container MessageContainer => _cosmosClient.GetDatabase("MainDatabase").GetContainer("Messages"); + + public async Task AddMessage(Message message) + { + var entity = ToEntity(message); + try + { + await MessageContainer.CreateItemAsync(entity); + } + catch (CosmosException e) + { + if (e.StatusCode == HttpStatusCode.Conflict) + { + throw new MessageAlreadyExistsException($"Message with id {message.MessageId} already exists"); + } + + if (e.StatusCode != HttpStatusCode.BadRequest) + { + throw new StorageUnavailableException($"Could not add message with id {message.MessageId}", e); + } + + throw; + } + } + + public async Task DeleteMessage(Message message) + { + try + { + await MessageContainer.DeleteItemAsync( + id: message.MessageId, + partitionKey: new PartitionKey(message.ConversationId) + ); + } + catch (CosmosException e) + { + if (e.StatusCode == HttpStatusCode.NotFound) + { + return; + } + + if (e.StatusCode != HttpStatusCode.BadRequest) + { + throw new StorageUnavailableException($"Could not delete message with id {message.MessageId}", e); + } + + throw; + } + } + + public async Task GetMessage(string conversationId, string messageId) + { + try + { + var message = await MessageContainer.ReadItemAsync(messageId, + new PartitionKey(conversationId), + new ItemRequestOptions + { + ConsistencyLevel = ConsistencyLevel.Session + }); + return ToMessage(message.Resource); + } + catch(CosmosException e) + { + if (e.StatusCode == HttpStatusCode.NotFound) + { + throw new MessageNotFoundException($"A message with id {messageId} does not exist"); + } + + if (e.StatusCode != HttpStatusCode.BadRequest) + { + throw new StorageUnavailableException($"Could not get message with id {messageId}", e); + } + + throw; + } + } + + public async Task GetMessages(GetMessagesParameters parameters) + { + var options = new QueryRequestOptions + { + MaxItemCount = int.Min(int.Max(parameters.Limit, 1), 100) + }; + + try + { + var query = MessageContainer.GetItemLinqQueryable(true, + string.IsNullOrEmpty(parameters.ContinuationToken) ? null : parameters.ContinuationToken, options) + .Where(m => m.partitionKey == parameters.ConversationId && + m.CreatedUnixTime > parameters.LastSeenMessageTime) + .OrderByDescending(m => m.CreatedUnixTime); + + using var iterator = query.ToFeedIterator(); + var response = await iterator.ReadNextAsync(); + var receivedMessages = response.Select(ToMessage).ToList(); + var newContinuationToken = response.ContinuationToken; + var conversationMessages = receivedMessages.Select(message => + new ConversationMessage(message.SenderUsername, message.Text, message.CreatedUnixTime)) + .ToList(); + return new GetMessagesResult(conversationMessages, newContinuationToken); + } + catch (CosmosException e) + { + if (e.StatusCode != HttpStatusCode.BadRequest) + { + throw new StorageUnavailableException($"Could not get messages for conversation with id {parameters.ConversationId}", e); + } + + throw; + } + } + + private static Message ToMessage(MessageEntity entity) + { + return new Message( + MessageId: entity.id, + SenderUsername: entity.SenderUsername, + Text: entity.MessageContent, + CreatedUnixTime: entity.CreatedUnixTime, + ConversationId: entity.partitionKey); + } + + private static MessageEntity ToEntity(Message message) + { + return new MessageEntity( + partitionKey: message.ConversationId, + id: message.MessageId, + SenderUsername: message.SenderUsername, + CreatedUnixTime: message.CreatedUnixTime, + MessageContent: message.Text); + } +}; \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Storage/StoragesImplementations/Cosmos/CosmosProfileStore.cs b/ChatApplication/ChatApplication.Web/Storage/StoragesImplementations/Cosmos/CosmosProfileStore.cs new file mode 100644 index 0000000..6cbe8ef --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Storage/StoragesImplementations/Cosmos/CosmosProfileStore.cs @@ -0,0 +1,129 @@ +using System.Net; +using ChatApplication.Exceptions; +using ChatApplication.Exceptions.StorageExceptions; +using ChatApplication.Storage.Entities; +using ChatApplication.Web.Dtos; +using Microsoft.Azure.Cosmos; + +namespace ChatApplication.Storage; + +public class CosmosProfileStore : IProfileStore +{ + private readonly CosmosClient _cosmosClient; + + + public CosmosProfileStore(CosmosClient cosmosClient) + { + _cosmosClient = cosmosClient; + } + + private Container Container => _cosmosClient.GetDatabase("MainDatabase").GetContainer("Profiles"); + + public async Task AddProfile(Profile profile) + { + if (profile == null || + string.IsNullOrWhiteSpace(profile.Username) || + string.IsNullOrWhiteSpace(profile.FirstName) || + string.IsNullOrWhiteSpace(profile.LastName) + ) + { + throw new ArgumentException($"Invalid profile {profile}", nameof(profile)); + } + + var entity = ToEntity(profile); + try + { + await Container.CreateItemAsync(entity); + } + catch (CosmosException e) + { + if (e.StatusCode == HttpStatusCode.Conflict) + { + throw new ProfileAlreadyExistsException($"Profile with username {profile.Username} already exists"); + } + + if(e.StatusCode != HttpStatusCode.BadRequest) + { + throw new StorageUnavailableException($"Could not add profile with username {profile.Username}", e); + } + + 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) + { + throw new ProfileNotFoundException($"Profile with username {username} does not exists"); + } + + if(e.StatusCode != HttpStatusCode.BadRequest) + { + throw new StorageUnavailableException($"Could not get profile with username {username}", e); + } + + 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; + } + + if(e.StatusCode != HttpStatusCode.BadRequest) + { + throw new StorageUnavailableException($"Could not delete profile with username {username}", e); + } + + throw; + } + } + + 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( + entity.id, + entity.firstName, + entity.lastName, + entity.ProfilePictureId + ); + } +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Storage/StoragesImplementations/SQL/SQLConversationStore.cs b/ChatApplication/ChatApplication.Web/Storage/StoragesImplementations/SQL/SQLConversationStore.cs new file mode 100644 index 0000000..5e616fd --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Storage/StoragesImplementations/SQL/SQLConversationStore.cs @@ -0,0 +1,260 @@ +using ChatApplication.Configuration; +using ChatApplication.Exceptions; +using ChatApplication.Storage.Models; +using ChatApplication.Web.Dtos; +using Dapper; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Options; + +namespace ChatApplication.Storage.SQL; + +public class SQLConversationStore : IConversationStore +{ + + private readonly string _connectionString; + + public SQLConversationStore(IOptions sqlSettings ) + { + _connectionString = sqlSettings.Value.ConnectionString; + } + + private SqlConnection GetSqlConnection() + { + return new SqlConnection(_connectionString); + } + public async Task GetUserConversation(string username, string conversationId) + { + var queryConversationTable = "SELECT * FROM Conversations WHERE ConversationId = @conversationId"; + + await using var sqlConnection = GetSqlConnection(); + await sqlConnection.OpenAsync(); + + var conversation = await sqlConnection.QueryFirstOrDefaultAsync(queryConversationTable, new {conversationId = conversationId}); + + if(conversation == null) + { + throw new ConversationNotFoundException($"A conversation with id {conversationId} was not found"); + } + + var queryConversationParticipantsTable = "SELECT Username FROM ConversationParticipants WHERE ConversationId = @conversationId"; + + var participantsUsernames = (await sqlConnection.QueryAsync( + queryConversationParticipantsTable, new {ConversationId = conversationId})).AsList(); + + List participants = new List(); + + foreach(var participantUsernames in participantsUsernames) + { + var query = "SELECT * FROM Profiles WHERE Username = @Username"; + var profile = await sqlConnection.QueryFirstOrDefaultAsync(query, new { Username = participantUsernames }); + + if (profile == null) + { + throw new ProfileNotFoundException($"A recipient with username {username} was not found"); + } + participants.Add(profile); + } + + await sqlConnection.CloseAsync(); + + return new UserConversation(conversationId, participants, conversation.ModifiedUnixTime, username); + + } + + public async Task UpdateConversationLastMessageTime(UserConversation senderConversation, long lastMessageTime) + { + var conversationId = senderConversation.ConversationId; + + await using var sqlConnection = GetSqlConnection(); + await sqlConnection.OpenAsync(); + + var checkQuery = "SELECT COUNT(*) FROM Conversations WHERE ConversationId = @ConversationId"; + int conversationCount = await sqlConnection.ExecuteScalarAsync(checkQuery, new { ConversationId = conversationId }); + + if (conversationCount == 0) + { + throw new ConversationNotFoundException($"A conversation with id {senderConversation.ConversationId} does not exist"); + } + + + var query = "UPDATE Conversations SET ModifiedUnixTime = @lastMessageTime WHERE ConversationId = @ConversationId"; + + await sqlConnection.ExecuteAsync(query, new { lastMessageTime, ConversationId = conversationId }); + await sqlConnection.CloseAsync(); + } + + public async Task CreateUserConversation(UserConversation userConversation) + { + var queryConversationTable = "INSERT INTO Conversations (ConversationId, ModifiedUnixTime) VALUES (@ConversationId, @ModifiedUnixTime)"; + var queryConversationParticipantsTable = "INSERT INTO ConversationParticipants (ConversationId, Username) VALUES (@ConversationId, @Username)"; + + await using var sqlConnection = GetSqlConnection(); + await sqlConnection.OpenAsync(); + + await using var transaction = sqlConnection.BeginTransaction(); + try + { + await sqlConnection.ExecuteAsync(queryConversationTable, + new + { + ConversationId = userConversation.ConversationId, + ModifiedUnixTime = userConversation.LastMessageTime + }, transaction); + + await sqlConnection.ExecuteAsync(queryConversationParticipantsTable, + new { ConversationId = userConversation.ConversationId, Username = userConversation.Username }, + transaction); + + foreach (var recipient in userConversation.Recipients) + { + await sqlConnection.ExecuteAsync(queryConversationParticipantsTable, + new { ConversationId = userConversation.ConversationId, Username = recipient.Username }, + transaction); + } + + transaction.Commit(); + } + catch (SqlException ex) + { + if (ex.Number == 2627) + { + transaction.Rollback(); + return; + } + transaction.Rollback(); + throw; + } + + await sqlConnection.CloseAsync(); + } + + public async Task DeleteUserConversation(UserConversation userConversation) + { + var queryConversationTable = "DELETE FROM Conversations WHERE ConversationId = @ConversationId AND ModifiedUnixTime = @ModifiedUnixTime"; + var queryConversationParticipantsTable = "DELETE FROM ConversationParticipants WHERE ConversationId = @ConversationId AND USERNAME = @Username"; + + await using var sqlConnection = GetSqlConnection(); + + await sqlConnection.OpenAsync(); + + + try + { + await sqlConnection.ExecuteAsync(queryConversationTable, + new + { + ConversationId = userConversation.ConversationId, + ModifiedUnixTime = userConversation.LastMessageTime + }); + + } + catch (Exception ex) + { + } + + await sqlConnection.CloseAsync(); + } + + + public async Task GetConversations(GetConversationsParameters parameters) + { + var limit = Math.Min(parameters.Limit, 100); + limit = Math.Max(limit, 1); + var parameterNormalized = parameters with { Limit = limit }; + + var queryConversationParticipantsTable = + @"SELECT cp.* + FROM ConversationParticipants cp + JOIN Conversations c ON cp.ConversationId = c.ConversationId + WHERE cp.Username = @Username + ORDER BY c.ModifiedUnixTime DESC + OFFSET @Offset ROWS + FETCH NEXT @Limit ROWS ONLY"; + + var offset = 0; + try + { + if (!string.IsNullOrEmpty(parameterNormalized.ContinuationToken)) + { + offset = Int32.Parse(parameterNormalized.ContinuationToken); + } + } + catch (Exception e) + { + throw new ArgumentException("A Bad Continutation token/Offset was passed"); + } + + await using var sqlConnection = GetSqlConnection(); + await sqlConnection.OpenAsync(); + + var conversationParticipants = (await sqlConnection.QueryAsync(queryConversationParticipantsTable, + new + { + Username = parameterNormalized.Username, + Offset = offset, + Limit = parameterNormalized.Limit + })).AsList(); + + var conversationIds = conversationParticipants.Select(conversationParticipant => + conversationParticipant.ConversationId).ToList(); + + var queryConversationParticipantsRecipients = + @"SELECT * FROM ConversationParticipants + WHERE ConversationId IN @ConversationIds AND Username != @Username"; + + var recipientsConversations = (await sqlConnection.QueryAsync(queryConversationParticipantsRecipients, + new + { + Username = parameterNormalized.Username, + ConversationIds = conversationIds + })).AsList(); + + var conversationRecipients = new Dictionary>(); + + foreach(var recipientConversation in recipientsConversations) + { + + var query = "SELECT * FROM Profiles WHERE Username = @Username"; + var profile = await sqlConnection.QueryFirstOrDefaultAsync(query, new { Username = recipientConversation.Username }); + + if (profile == null) + { + throw new ProfileNotFoundException($"A recipient with username {recipientConversation.Username} was not found"); + } + + if (conversationRecipients.ContainsKey(recipientConversation.ConversationId)) + { + conversationRecipients[recipientConversation.ConversationId].Add(profile); + } + else + { + conversationRecipients.Add(recipientConversation.ConversationId, new List {profile}); + } + } + + var queryConversationsTable = @"SELECT * FROM Conversations + WHERE ConversationId IN @ConversationIds AND ModifiedUnixTime > @LastSeenConversationTime + ORDER BY ModifiedUnixTime DESC "; + + var conversations = (await sqlConnection.QueryAsync( + queryConversationsTable, new + { + ConversationIds = conversationIds, + Offset = offset, + Limit = parameterNormalized.Limit, + LastSeenConversationTime = parameterNormalized.LastSeenConversationTime + })).AsList(); + + var newOffset = offset + conversations.Count; + + var userConversations = conversations.Select( + conversation => new UserConversation(conversation.ConversationId, + conversationRecipients[conversation.ConversationId], conversation.ModifiedUnixTime, + parameterNormalized.Username)).ToList(); + + await sqlConnection.CloseAsync(); + + string? newOffsetString = conversations.Count < parameterNormalized.Limit ? null : newOffset.ToString(); + return new GetConversationsResult(userConversations, newOffsetString); + } +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Storage/StoragesImplementations/SQL/SQLMessageStore.cs b/ChatApplication/ChatApplication.Web/Storage/StoragesImplementations/SQL/SQLMessageStore.cs new file mode 100644 index 0000000..40d8f7a --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Storage/StoragesImplementations/SQL/SQLMessageStore.cs @@ -0,0 +1,139 @@ +using ChatApplication.Configuration; +using ChatApplication.Exceptions; +using ChatApplication.Web.Dtos; +using Dapper; +using Microsoft.Azure.Cosmos; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Options; + +namespace ChatApplication.Storage.SQL; + +public class SQLMessageStore : IMessageStore +{ + private readonly string _connectionString; + + public SQLMessageStore(IOptions sqlSettings) + { + _connectionString = sqlSettings.Value.ConnectionString; + } + + private SqlConnection GetSqlConnection() + { + return new SqlConnection(_connectionString); + } + + + public async Task AddMessage(Message message) + { + var query = @"INSERT INTO Messages (MessageId, SenderUsername, ConversationId, Text, CreatedUnixTime) + VALUES (@MessageId, @SenderUsername, @ConversationId, @Text, @CreatedUnixTime)"; + + await using var sqlConnection = GetSqlConnection(); + await sqlConnection.OpenAsync(); + + try + { + await sqlConnection.ExecuteAsync(query, message); + } + catch (SqlException ex) + { + if (ex.Number == 2627) + { + throw new MessageAlreadyExistsException($"Message with Id {ex.Message} already exists"); + } + + throw; + } + await sqlConnection.CloseAsync(); + } + + public async Task GetMessages(GetMessagesParameters parameters) + { + var limit = Math.Min(parameters.Limit, 100); + limit = Math.Max(limit, 1); + var parameterNormalized = parameters with { Limit = limit }; + + var query = @"SELECT * FROM Messages WHERE ConversationId = @ConversationId + AND CreatedUnixTime > @lastSeenMessageTime + ORDER BY CreatedUnixTime DESC + OFFSET @Offset ROWS + FETCH NEXT @Limit ROWS ONLY"; + + var offset = 0; + try + { + if (!string.IsNullOrEmpty(parameterNormalized.ContinuationToken)) + { + offset = Int32.Parse(parameterNormalized.ContinuationToken); + } + } + catch (Exception e) + { + throw new ArgumentException("A Bad Continutation token/Offset was passed"); + } + + await using var sqlConnection = GetSqlConnection(); + await sqlConnection.OpenAsync(); + + var messages = (await sqlConnection.QueryAsync(query, new + { + ConversationId = parameterNormalized.ConversationId, + lastSeenMessageTime = parameterNormalized.LastSeenMessageTime, + Limit = parameterNormalized.Limit, + Offset = offset + })).AsList(); + + int newOffset = offset + messages.Count; + + var conversationMessages = messages.Select(message => new + ConversationMessage(message.SenderUsername, message.Text, message.CreatedUnixTime)).ToList(); + + await sqlConnection.CloseAsync(); + + string? newOffsetString = messages.Count < parameterNormalized.Limit ? null : newOffset.ToString(); + return new GetMessagesResult(conversationMessages, newOffsetString); + } + + public async Task DeleteMessage(Message message) + { + + await using var sqlConnection = GetSqlConnection(); + await sqlConnection.OpenAsync(); + + var query = @"DELETE FROM Messages WHERE MessageId = @MessageId"; + try + { + await sqlConnection.ExecuteAsync(query, message); + } + catch (SqlException ex) + { + if (ex.Number == 2627) + { + return; + } + + throw; + } + + await sqlConnection.CloseAsync(); + } + + public async Task GetMessage(string conversationId, string messageId) + { + + await using var sqlConnection = GetSqlConnection(); + await sqlConnection.OpenAsync(); + + var query = "SELECT * FROM Messages WHERE ConversationId = @ConversationId AND MessageId = @MessageId"; + var message = await sqlConnection.QueryFirstOrDefaultAsync(query, new { ConversationId = conversationId, MessageId = messageId }); + + if (message == null) + { + throw new MessageNotFoundException($"A message with id {messageId} does not exist"); + } + + await sqlConnection.CloseAsync(); + return message; + + } +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Storage/StoragesImplementations/SQL/SQLProfileStore.cs b/ChatApplication/ChatApplication.Web/Storage/StoragesImplementations/SQL/SQLProfileStore.cs new file mode 100644 index 0000000..78dc62c --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Storage/StoragesImplementations/SQL/SQLProfileStore.cs @@ -0,0 +1,85 @@ +using ChatApplication.Configuration; +using ChatApplication.Exceptions; +using ChatApplication.Web.Dtos; +using Dapper; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Options; + +namespace ChatApplication.Storage.SQL; + +public class SQLProfileStore : IProfileStore +{ + + private readonly string _connectionString; + + + public SQLProfileStore(IOptions sqlSettings) + { + _connectionString = sqlSettings.Value.ConnectionString; + } + + private SqlConnection GetSqlConnection() + { + return new SqlConnection(_connectionString); + } + + public async Task AddProfile(Profile profile) + { + var query = @"INSERT INTO Profiles (Username, FirstName, LastName, ProfilePictureId) + VALUES (TRIM(@Username), TRIM(@FirstName), TRIM(@LastName), TRIM(@ProfilePictureId))"; + + await using var sqlConnection = GetSqlConnection(); + await sqlConnection.OpenAsync(); + + try + { + await sqlConnection.ExecuteAsync(query, profile); + } + + catch (SqlException ex) + { + if (ex.Number == 2627) + { + throw new ProfileAlreadyExistsException($"Profile with username {profile.Username} already exists"); + } + else + { + throw; + } + } + + await sqlConnection.CloseAsync(); + } + + + public async Task GetProfile(string username) + { + var query = "SELECT * FROM Profiles WHERE Username = @Username"; + + await using var sqlConnection = GetSqlConnection(); + await sqlConnection.OpenAsync(); + + var profile = await sqlConnection.QueryFirstOrDefaultAsync(query, new { Username = username }); + + if (profile == null) + { + throw new ProfileNotFoundException($"A profile with username {username} was not found"); + } + + await sqlConnection.CloseAsync(); + return profile; + } + + public async Task DeleteProfile(string username) + { + var query = "DELETE FROM Profiles WHERE Username = @Username"; + + await using var sqlConnection = GetSqlConnection(); + await sqlConnection.OpenAsync(); + + await sqlConnection.ExecuteAsync(query, new { Username = username }); + + await sqlConnection.CloseAsync(); + + } +} \ No newline at end of file diff --git a/ChatApplication/ChatApplication.Web/Utils/Image.cs b/ChatApplication/ChatApplication.Web/Utils/Image.cs new file mode 100644 index 0000000..211c5d8 --- /dev/null +++ b/ChatApplication/ChatApplication.Web/Utils/Image.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatApplication.Utils; + +public record Image( + [Required] byte[] ImageData, + [Required] string ContentType); + + diff --git a/ChatApplication/ChatApplication.Web/appsettings.Development.json b/ChatApplication/ChatApplication.Web/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/ChatApplication/ChatApplication.Web/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/ChatApplication/ChatApplication.Web/appsettings.json b/ChatApplication/ChatApplication.Web/appsettings.json new file mode 100644 index 0000000..114f08b --- /dev/null +++ b/ChatApplication/ChatApplication.Web/appsettings.json @@ -0,0 +1,29 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + }, + "ApplicationInsights": { + "LogLevel": { + "Default": "Information" + } + } + }, + "Cosmos": { + "ConnectionString": "" + }, + "BlobStorage": { + "ConnectionString": "", + "ContainerName": "profilepictures" + }, + "ServiceBus": { + "ConnectionString": "", + "AddMessageQueueName": "addmessagequeue", + "StartConversationQueueName": "startconversationqueue" + }, + "SQL": { + "ConnectionString": "" + }, + "AllowedHosts": "*" +} diff --git a/ChatApplication/ChatApplication.sln b/ChatApplication/ChatApplication.sln new file mode 100644 index 0000000..ffcab8d --- /dev/null +++ b/ChatApplication/ChatApplication.sln @@ -0,0 +1,28 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatApplication.Web", "ChatApplication.Web\ChatApplication.Web.csproj", "{10D874D8-8085-4889-946E-5FBFD6075AFC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatApplication.Web.Tests", "ChatApplication.Web.Tests\ChatApplication.Web.Tests.csproj", "{EC7AF3BE-BB7F-4BBA-B754-3C06C2061F62}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatApplication.Web.IntegrationTests", "ChatApplication.Web.IntegrationTests\ChatApplication.Web.IntegrationTests.csproj", "{4671F871-356F-4B9D-8873-0A8CC24D734A}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {10D874D8-8085-4889-946E-5FBFD6075AFC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {10D874D8-8085-4889-946E-5FBFD6075AFC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {10D874D8-8085-4889-946E-5FBFD6075AFC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {10D874D8-8085-4889-946E-5FBFD6075AFC}.Release|Any CPU.Build.0 = Release|Any CPU + {EC7AF3BE-BB7F-4BBA-B754-3C06C2061F62}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EC7AF3BE-BB7F-4BBA-B754-3C06C2061F62}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EC7AF3BE-BB7F-4BBA-B754-3C06C2061F62}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EC7AF3BE-BB7F-4BBA-B754-3C06C2061F62}.Release|Any CPU.Build.0 = Release|Any CPU + {4671F871-356F-4B9D-8873-0A8CC24D734A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4671F871-356F-4B9D-8873-0A8CC24D734A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4671F871-356F-4B9D-8873-0A8CC24D734A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4671F871-356F-4B9D-8873-0A8CC24D734A}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md index c87b373..894b579 100644 --- a/README.md +++ b/README.md @@ -1 +1,28 @@ -# ChatApplication \ No newline at end of file +# ChatApplication + + +The Chat Service Backend Application is a C# chat application built using ASP.NET Core and .NET 7. This application provides the backend functionality for a real-time chat web application. It utilizes Microsoft Azure for deployment and utilizes various Azure services for seamless integration and scalability. It is based on our EECE 503 course at the American University of Beirut : Web Services in the Cloud. + +## Deployment + +The Chat Service Backend Application is deployed on Microsoft Azure and can be accessed at the following URL: https://chatservice-jad-ronald.azurewebsites.net/ + +## Technologies used + +The Chat Service Backend Application is built using the following technologies: + +- C# +- ASP.NET Core +- NET 7 +- Microsoft Azure + - Cosmos DB + - SQL databases + - Azure App Service + - Service Bus + +## Contributors + +- Ronald Kassab +- Jad Moukaddam + +We would also like to thank Dr Nehme Bilal for his continuous support and help throughout this project.