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..05dc7ab 100644 --- a/README.md +++ b/README.md @@ -1 +1,4 @@ -# ChatApplication \ No newline at end of file +# ChatApplication + + +This is the GitHub repository for our semester project of EECE 503E : Web Services in the Cloud