diff --git a/.github/workflows/azure_deploy.yml b/.github/workflows/azure_deploy.yml new file mode 100644 index 0000000..e3e17ba --- /dev/null +++ b/.github/workflows/azure_deploy.yml @@ -0,0 +1,50 @@ +name: Build, Test, and Deploy to Azure + +on: + workflow_dispatch: + push: + branches: + - main + +env: + AZURE_WEBAPP_NAME: chat-service-ali-nadim + AZURE_WEBAPP_PACKAGE_PATH: './publish' + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + steps: + + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: '6.0.x' + + - name: Restore dependencies + run: dotnet restore ./ChatService.sln + + - name: Build + run: dotnet build ./ChatService.sln --configuration Release --no-restore + + - name: Run unit tests + run: dotnet test ChatService.Web.Tests/bin/Release/net6.0/ChatService.Web.Tests.dll + + - name: Run integration tests + run: dotnet test ChatService.Web.IntegrationTests/bin/Release/net6.0/ChatService.Web.IntegrationTests.dll + env: + Cosmos:ConnectionString: ${{ secrets.COSMOS_CONNECTIONSTRING }} + BlobStorage:ConnectionString: ${{ secrets.BLOBSTORAGE_CONNECTIONSTRING }} + + - name: Publish + run: dotnet publish --configuration Release --output '${{ env.AZURE_WEBAPP_PACKAGE_PATH }}' --no-restore ChatService.Web + + - name: Deploy + uses: azure/webapps-deploy@v2 + with: + app-name: ${{ env.AZURE_WEBAPP_NAME }} + publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_CHATSERVICE }} + package: ${{ env.AZURE_WEBAPP_PACKAGE_PATH }} diff --git a/.github/workflows/prbuild.yml b/.github/workflows/prbuild.yml new file mode 100644 index 0000000..71fe75b --- /dev/null +++ b/.github/workflows/prbuild.yml @@ -0,0 +1,35 @@ +name: PR Build + +on: + pull_request: + branches: [ "main" ] + +jobs: + build-and-test: + + runs-on: ubuntu-latest + + steps: + + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 6.0.x + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --configuration Release --no-restore + + - name: Run unit tests + run: dotnet test ChatService.Web.Tests/bin/Release/net6.0/ChatService.Web.Tests.dll + + - name: Run integration tests + run: dotnet test ChatService.Web.IntegrationTests/bin/Release/net6.0/ChatService.Web.IntegrationTests.dll + env: + Cosmos:ConnectionString: ${{ secrets.COSMOS_CONNECTIONSTRING }} + BlobStorage:ConnectionString: ${{ secrets.BLOBSTORAGE_CONNECTIONSTRING }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f590aee --- /dev/null +++ b/.gitignore @@ -0,0 +1,456 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# JetBrains Rider +.idea/ +*.sln.iml + +## +## Visual Studio Code +## +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +ThirdPartyNotice.txt \ No newline at end of file diff --git a/ChatService.Web.IntegrationTests/ChatService.Web.IntegrationTests.csproj b/ChatService.Web.IntegrationTests/ChatService.Web.IntegrationTests.csproj new file mode 100644 index 0000000..3f79c19 --- /dev/null +++ b/ChatService.Web.IntegrationTests/ChatService.Web.IntegrationTests.csproj @@ -0,0 +1,32 @@ + + + + net6.0 + enable + enable + + false + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/ChatService.Web.IntegrationTests/CosmosConversationStoreIntegrationTests.cs b/ChatService.Web.IntegrationTests/CosmosConversationStoreIntegrationTests.cs new file mode 100644 index 0000000..7eb76f6 --- /dev/null +++ b/ChatService.Web.IntegrationTests/CosmosConversationStoreIntegrationTests.cs @@ -0,0 +1,270 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Enums; +using ChatService.Web.Exceptions; +using ChatService.Web.Storage; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace ChatService.Web.IntegrationTests; + +public class CosmosConversationStoreIntegrationTests : IClassFixture>, IAsyncLifetime +{ + private readonly IUserConversationStore _userConversationStore; + + private static readonly string _username = Guid.NewGuid().ToString(); + + private GetUserConversationsParameters _parameters = new() + { + Limit = 10, + Order = OrderBy.DESC, + ContinuationToken = null, + LastSeenConversationTime = 0 + }; + + private static readonly UserConversation _userConversation = CreateUserConversation( + lastModifiedTime: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + private static readonly UserConversation _userConversation1 = CreateUserConversation(lastModifiedTime: 100); + private static readonly UserConversation _userConversation2 = CreateUserConversation(lastModifiedTime: 200); + private static readonly UserConversation _userConversation3 = CreateUserConversation(lastModifiedTime: 300); + + private List _userConversations = new() { + _userConversation1, _userConversation2, _userConversation3, _userConversation + }; + + public CosmosConversationStoreIntegrationTests(WebApplicationFactory factory) + { + _userConversationStore = factory.Services.GetRequiredService(); + } + + [Fact] + public async Task CreateUserConversation_Success() + { + await _userConversationStore.UpsertUserConversation(_userConversation); + var receivedConversation = await _userConversationStore.GetUserConversation( + _userConversation.Username, _userConversation.ConversationId); + + Assert.Equal(_userConversation, receivedConversation); + } + + [Fact] + public async Task UpdateUserConversation_Success() + { + await _userConversationStore.UpsertUserConversation(_userConversation); + var receivedConversation = await _userConversationStore.GetUserConversation( + _userConversation.Username, _userConversation.ConversationId); + + Assert.Equal(_userConversation, receivedConversation); + + _userConversation.LastModifiedTime = 100; + await _userConversationStore.UpsertUserConversation(_userConversation); + receivedConversation = await _userConversationStore.GetUserConversation( + _userConversation.Username, _userConversation.ConversationId); + + Assert.Equal(_userConversation, receivedConversation); + } + + [Theory] + [InlineData(null, "dummyConversationId", 100)] + [InlineData("", "dummyConversationId", 100)] + [InlineData(" ", "dummyConversationId", 100)] + [InlineData("foobar", null, 100)] + [InlineData("foobar", "", 100)] + [InlineData("foobar", " ", 100)] + [InlineData("foobar", "dummyConversationId", -100)] + public async Task CreateUserConversation_InvalidArguments(string username, string conversationId, long lastModifiedTime) + { + UserConversation userConversation = new() + { + Username = username, + ConversationId = conversationId, + LastModifiedTime = lastModifiedTime + }; + await Assert.ThrowsAsync( + () => _userConversationStore.UpsertUserConversation(userConversation)); + } + + [Theory] + [InlineData(null, "dummyConversationId")] + [InlineData("", "dummyConversationId")] + [InlineData(" ", "dummyConversationId")] + [InlineData("foobar", null)] + [InlineData("foobar", "")] + [InlineData("foobar", " ")] + public async Task GetUserConversation_InvalidArguments(string username, string conversationId) + { + await Assert.ThrowsAsync( + () => _userConversationStore.GetUserConversation(username, conversationId)); + } + + [Fact] + public async Task GetUserConversation_ConversationNotFound() + { + Assert.Null( + await _userConversationStore.GetUserConversation(_userConversation.Username, _userConversation.ConversationId)); + } + + [Fact] + public async Task GetUserConversations_Limit() + { + await AddMultipleUserConversations(_userConversation1, _userConversation2, _userConversation3); + + _parameters.Limit = 1; + var response = await _userConversationStore.GetUserConversations(_userConversation.Username, _parameters); + Assert.Single(response.UserConversations); + + _parameters.Limit = 2; + response = await _userConversationStore.GetUserConversations(_userConversation.Username, _parameters); + Assert.Equal(2, response.UserConversations.Count); + + _parameters.Limit = 3; + response = await _userConversationStore.GetUserConversations(_userConversation.Username, _parameters); + Assert.Equal(3, response.UserConversations.Count); + } + + [Theory] + [InlineData(OrderBy.ASC)] + [InlineData(OrderBy.DESC)] + public async Task GetUserConversations_OrderBy(OrderBy orderBy) + { + List userConversationsExpected = CreateListOfUserConversations( + _userConversation1, _userConversation2, _userConversation3, _userConversation); + + await AddMultipleUserConversations( + _userConversation, _userConversation1, _userConversation2, _userConversation3); + + _parameters.Order = orderBy; + var response = await _userConversationStore.GetUserConversations(_userConversation.Username, _parameters); + + if (orderBy == OrderBy.ASC) + { + Assert.Equal(userConversationsExpected, response.UserConversations); + } + else + { + userConversationsExpected.Reverse(); + Assert.Equal(userConversationsExpected, response.UserConversations); + } + } + + [Fact] + public async Task GetUserConversations_ContinuationTokenValidity() + { + await AddMultipleUserConversations(_userConversation1, _userConversation2, _userConversation3); + + _parameters.Limit = 1; + + var response = await _userConversationStore.GetUserConversations(_userConversation.Username, _parameters); + + Assert.Equal(_userConversation3, response.UserConversations.ElementAt(0)); + + var nextContinuationToken = response.NextContinuationToken; + Assert.NotNull(nextContinuationToken); + + _parameters.ContinuationToken = nextContinuationToken; + response = await _userConversationStore.GetUserConversations(_userConversation.Username, _parameters); + Assert.Equal(_userConversation2, response.UserConversations.ElementAt(0)); + + nextContinuationToken = response.NextContinuationToken; + Assert.NotNull(nextContinuationToken); + + _parameters.ContinuationToken = nextContinuationToken; + response = await _userConversationStore.GetUserConversations(_userConversation.Username, _parameters); + Assert.Equal(_userConversation1, response.UserConversations.ElementAt(0)); + + nextContinuationToken = response.NextContinuationToken; + Assert.Null(nextContinuationToken); + } + + [Theory] + [InlineData(0)] + [InlineData(100)] + [InlineData(200)] + [InlineData(300)] + public async Task GetUserConversations_LastSeenConversationTime(long lastSeenConversationTime) + { + await AddMultipleUserConversations(_userConversation1, _userConversation2, _userConversation3, _userConversation); + + List userConversationsExpected = new(); + + foreach (UserConversation userConversation in _userConversations) + { + if (userConversation.LastModifiedTime > lastSeenConversationTime) + { + userConversationsExpected.Add(userConversation); + } + } + userConversationsExpected.Reverse(); + + _parameters.LastSeenConversationTime = lastSeenConversationTime; + var response = await _userConversationStore.GetUserConversations(_userConversation.Username, _parameters); + + Assert.Equal(userConversationsExpected, response.UserConversations); + } + + [Theory] + [InlineData("", 1, 100)] + [InlineData(" ", 1, 100)] + [InlineData(null, 0, 100)] + [InlineData("username", 0, 100)] + [InlineData("username", -1, 100)] + [InlineData("username", 10, -100)] + public async Task GetUserConversations_InvalidArguments(string username, int limit, long lastSeenConversationTime) + { + _parameters.Limit = limit; + _parameters.LastSeenConversationTime = lastSeenConversationTime; + await Assert.ThrowsAsync( + () => _userConversationStore.GetUserConversations(username, _parameters)); + } + + [Fact] + public async Task GetUserConversations_InvalidContinuationToken() + { + string invalidContinuationToken = Guid.NewGuid().ToString(); + _parameters.ContinuationToken = invalidContinuationToken; + + await Assert.ThrowsAsync( + () => _userConversationStore.GetUserConversations(_userConversation.Username, _parameters)); + } + + private async Task AddMultipleUserConversations(params UserConversation[] userConversations) + { + foreach (UserConversation userConversation in userConversations) + { + await _userConversationStore.UpsertUserConversation(userConversation); + } + } + + private List CreateListOfUserConversations(params UserConversation[] userConversations) + { + List list = new(); + + foreach (UserConversation userConversation in userConversations) + { + list.Add(userConversation); + } + + return list; + } + + private static UserConversation CreateUserConversation(long lastModifiedTime) + { + return new UserConversation + { + Username = _username, + ConversationId = Guid.NewGuid().ToString(), + LastModifiedTime = lastModifiedTime + }; + } + + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + await Task.WhenAll(_userConversations.Select( + userConversation => _userConversationStore.DeleteUserConversation( + userConversation.Username, userConversation.ConversationId))); + } +} \ No newline at end of file diff --git a/ChatService.Web.IntegrationTests/CosmosMessageStoreIntegrationTests.cs b/ChatService.Web.IntegrationTests/CosmosMessageStoreIntegrationTests.cs new file mode 100644 index 0000000..6f1ed9b --- /dev/null +++ b/ChatService.Web.IntegrationTests/CosmosMessageStoreIntegrationTests.cs @@ -0,0 +1,259 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Enums; +using ChatService.Web.Exceptions; +using ChatService.Web.Storage; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace ChatService.Web.IntegrationTests; + +public class CosmosMessageStoreIntegrationTests : IClassFixture>, IAsyncLifetime +{ + private readonly IMessageStore _messageStore; + + private readonly string _conversationId = Guid.NewGuid().ToString(); + + private GetMessagesParameters _parameters = new() + { + Limit = 1, + Order = OrderBy.ASC, + ContinuationToken = null, + LastSeenMessageTime = 0 + }; + + private static readonly Message _message1 = CreateMessage(unixTime: 100, text: "text of _message1"); + private static readonly Message _message2 = CreateMessage(unixTime: 200, text: "text of _message2"); + private static readonly Message _message3 = CreateMessage(unixTime: 300, text: "text of _message3"); + + private List _messages = new() { _message1, _message2, _message3 }; + + public CosmosMessageStoreIntegrationTests(WebApplicationFactory factory) + { + _messageStore = factory.Services.GetRequiredService(); + } + + [Fact] + public async Task AddMessage_Success() + { + await _messageStore.AddMessage(_conversationId, _message1); + var receivedMessage = await _messageStore.GetMessage(_conversationId, _message1.Id); + Assert.Equal(_message1, receivedMessage); + } + + [Theory] + [InlineData(null, "senderUsername", "text", 100)] + [InlineData("", "senderUsername", "text", 100)] + [InlineData(" ", "senderUsername", "text", 100)] + [InlineData("id", null, "text", 100)] + [InlineData("id", "", "text", 100)] + [InlineData("id", " ", "text", 100)] + [InlineData("id", "senderUsername", null, 100)] + [InlineData("id", "senderUsername", "", 100)] + [InlineData("id", "senderUsername", " ", 100)] + [InlineData("id", "senderUsername", "text", -100)] + public async Task AddMessage_InvalidArguments(string id, string senderUsername, string text, long unixTime) + { + Message message = new() + { + Id = id, + UnixTime = unixTime, + SenderUsername = senderUsername, + Text = text + }; + + await Assert.ThrowsAsync(() => _messageStore.AddMessage(_conversationId, message)); + } + + [Fact] + public async Task AddMessage_MessageAlreadyExists() + { + await _messageStore.AddMessage(_conversationId, _message1); + await Assert.ThrowsAsync(() => _messageStore.AddMessage(_conversationId, _message1)); + } + + [Fact] + public async Task UpdateMessageTime_Success() + { + await _messageStore.AddMessage(_conversationId, _message1); + var receivedMessage = await _messageStore.GetMessage(_conversationId, _message1.Id); + Assert.Equal(_message1, receivedMessage); + + _message1.UnixTime = 200; + await _messageStore.UpdateMessageTime(_conversationId, _message1); + receivedMessage = await _messageStore.GetMessage(_conversationId, _message1.Id); + Assert.Equal(_message1, receivedMessage); + } + + [Theory] + [InlineData(null, "messageId")] + [InlineData("", "messageId")] + [InlineData(" ", "messageId")] + [InlineData("conversationId", null)] + [InlineData("conversationId", "")] + [InlineData("conversationId", " ")] + public async Task GetMessage_InvalidArguments(string conversationId, string messageId) + { + await Assert.ThrowsAsync(() => _messageStore.GetMessage(conversationId, messageId)); + } + + [Fact] + public async Task GetMessage_MessageNotFound() + { + var message = await _messageStore.GetMessage(_conversationId, _message1.Id); + Assert.Null(message); + } + + [Fact] + public async Task GetMessages_Limit() + { + await AddMultipleMessages(_conversationId, _message1, _message2, _message3); + + var response = await _messageStore.GetMessages(_conversationId, _parameters); + Assert.Single(response.Messages); + + _parameters.Limit = 2; + response = await _messageStore.GetMessages(_conversationId, _parameters); + Assert.Equal(2, response.Messages.Count); + + _parameters.Limit = 3; + response = await _messageStore.GetMessages(_conversationId, _parameters); + Assert.Equal(3, response.Messages.Count); + } + + [Theory] + [InlineData(OrderBy.ASC)] + [InlineData(OrderBy.DESC)] + public async Task GetMessages_OrderBy(OrderBy orderBy) + { + await AddMultipleMessages(_conversationId, _message1, _message2); + + List messagesExpected = new() { _message1, _message2 }; + + _parameters.Limit = 10; + _parameters.Order = orderBy; + var response = await _messageStore.GetMessages(_conversationId, _parameters); + + if (orderBy == OrderBy.ASC) + { + Assert.Equal(messagesExpected, response.Messages); + } + else + { + messagesExpected.Reverse(); + Assert.Equal(messagesExpected, response.Messages); + } + } + + [Fact] + public async Task GetMessages_ContinuationTokenValidity() + { + await AddMultipleMessages(_conversationId, _message1, _message2, _message3); + + var response = await _messageStore.GetMessages(_conversationId, _parameters); + Assert.Equal(_message1, response.Messages.ElementAt(0)); + Assert.NotNull(response.NextContinuationToken); + + _parameters.ContinuationToken = response.NextContinuationToken; + response = await _messageStore.GetMessages(_conversationId, _parameters); + Assert.Equal(_message2, response.Messages.ElementAt(0)); + Assert.NotNull(response.NextContinuationToken); + + _parameters.ContinuationToken = response.NextContinuationToken; + response = await _messageStore.GetMessages(_conversationId, _parameters); + Assert.Equal(_message3, response.Messages.ElementAt(0)); + Assert.Null(response.NextContinuationToken); + } + + [Theory] + [InlineData(0)] + [InlineData(100)] + [InlineData(200)] + [InlineData(300)] + public async Task GetMessages_LastSeenMessageTime(long lastSeenMessageTime) + { + await AddMultipleMessages(_conversationId, _message1, _message2, _message3); + + List messagesExpected = new(); + foreach (Message message in _messages) + { + if (message.UnixTime > lastSeenMessageTime) + { + messagesExpected.Add(message); + } + } + + _parameters.Limit = 10; + _parameters.LastSeenMessageTime = lastSeenMessageTime; + var response = await _messageStore.GetMessages(_conversationId, _parameters); + + Assert.Equal(messagesExpected, response.Messages); + } + + [Theory] + [InlineData(null, 10, 100)] + [InlineData("", 10, 100)] + [InlineData(" ", 10, 100)] + [InlineData("conversationId", 0, 100)] + [InlineData("conversationId", -10, 100)] + [InlineData("conversationId", 10, -100)] + public async Task GetMessages_InvalidArguments(string conversationId, int limit, long lastSeenMessageTime) + { + _parameters.Limit = limit; + _parameters.LastSeenMessageTime = lastSeenMessageTime; + await Assert.ThrowsAsync(() => _messageStore.GetMessages(conversationId, _parameters)); + } + + [Fact] + public async Task GetMessages_InvalidContinuationToken() + { + string invalidContinuationToken = Guid.NewGuid().ToString(); + _parameters.Limit = 10; + _parameters.Order = OrderBy.DESC; + _parameters.ContinuationToken = invalidContinuationToken; + await Assert.ThrowsAsync( + () => _messageStore.GetMessages(_conversationId, _parameters)); + } + + [Fact] + public async Task ConversationPartitionExists_Exists() + { + await _messageStore.AddMessage(_conversationId, _message1); + Assert.True(await _messageStore.ConversationExists(_conversationId)); + } + + [Fact] + public async Task ConversationPartitionExists_DoesNotExists() + { + Assert.False(await _messageStore.ConversationExists(_conversationId)); + } + + private async Task AddMultipleMessages(string conversationId, params Message[] messages) + { + foreach (Message message in messages) + { + await _messageStore.AddMessage(conversationId, message); + } + } + + private static Message CreateMessage(int unixTime, string text) + { + return new Message() + { + Id = Guid.NewGuid().ToString(), + UnixTime = unixTime, + SenderUsername = Guid.NewGuid().ToString(), + Text = text + }; + } + + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + await Task.WhenAll(_messages.Select( + message => _messageStore.DeleteMessage(_conversationId, message.Id))); + } +} \ No newline at end of file diff --git a/ChatService.Web.IntegrationTests/CosmosProfileStoreIntegrationTests.cs b/ChatService.Web.IntegrationTests/CosmosProfileStoreIntegrationTests.cs new file mode 100644 index 0000000..6ddc867 --- /dev/null +++ b/ChatService.Web.IntegrationTests/CosmosProfileStoreIntegrationTests.cs @@ -0,0 +1,109 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Exceptions; +using ChatService.Web.Storage; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace ChatService.Web.IntegrationTests; + +public class CosmosProfileStoreIntegrationTests : IClassFixture>, IAsyncLifetime +{ + private readonly IProfileStore _profileStore; + + private readonly Profile _profile = new() + { + Username = Guid.NewGuid().ToString(), + FirstName = "Foo", + LastName = "Bar", + ProfilePictureId = "dummy_id" + }; + + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + await _profileStore.DeleteProfile(_profile.Username); + } + + public CosmosProfileStoreIntegrationTests(WebApplicationFactory factory) + { + _profileStore = factory.Services.GetRequiredService(); + } + + [Fact] + public async Task AddNewProfile_Success() + { + await _profileStore.AddProfile(_profile); + Assert.Equal(_profile, await _profileStore.GetProfile(_profile.Username)); + } + + [Theory] + [InlineData(null, "Foo", "Bar", "dummy_id")] + [InlineData("", "Foo", "Bar", "dummy_id")] + [InlineData(" ", "Foo", "Bar", "dummy_id")] + [InlineData("foobar", null, "Bar", "dummy_id")] + [InlineData("foobar", "", "Bar", "dummy_id")] + [InlineData("foobar", " ", "Bar", "dummy_id")] + [InlineData("foobar", "Foo", null, "dummy_id")] + [InlineData("foobar", "Foo", "", "dummy_id")] + [InlineData("foobar", "Foo", " ", "dummy_id")] + // [InlineData("foobar", "Foo", "Bar", null)] + // [InlineData("foobar", "Foo", "Bar","")] + // [InlineData("foobar", "Foo", "Bar"," ")] + public async Task AddNewProfile_InvalidArgs(string username, string firstName, string lastName, string profilePictureId) + { + Profile profile = new() + { + Username = username, + FirstName = firstName, + LastName = lastName, + ProfilePictureId = profilePictureId + }; + + await Assert.ThrowsAsync( async () => await _profileStore.AddProfile(profile)); + } + + [Fact] + public async Task AddNewProfile_NullProfile() + { + await Assert.ThrowsAsync( async () => await _profileStore.AddProfile(null)); + } + + [Fact] + public async Task AddNewProfile_UsernameTaken() + { + await _profileStore.AddProfile(_profile); + await Assert.ThrowsAsync( async () => await _profileStore.AddProfile(_profile)); + } + + [Fact] + public async Task GetNonExistingProfile() + { + Assert.Null(await _profileStore.GetProfile(_profile.Username)); + } + + [Fact] + public async Task DeleteProfile() + { + await _profileStore.AddProfile(_profile); + Assert.Equal(_profile, await _profileStore.GetProfile(_profile.Username)); + await _profileStore.DeleteProfile(_profile.Username); + Assert.Null(await _profileStore.GetProfile(_profile.Username)); + } + + [Fact] + public async Task ProfileExists_Exists() + { + await _profileStore.AddProfile(_profile); + Assert.True(await _profileStore.ProfileExists(_profile.Username)); + } + + [Fact] + public async Task ProfileExists_DoesNotExist() + { + Assert.False(await _profileStore.ProfileExists(_profile.Username)); + } +} \ No newline at end of file diff --git a/ChatService.Web.IntegrationTests/ImageStoreIntegrationTests.cs b/ChatService.Web.IntegrationTests/ImageStoreIntegrationTests.cs new file mode 100644 index 0000000..3763d18 --- /dev/null +++ b/ChatService.Web.IntegrationTests/ImageStoreIntegrationTests.cs @@ -0,0 +1,87 @@ +using System.Text; +using ChatService.Web.Dtos; +using ChatService.Web.Storage; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace ChatService.Web.IntegrationTests; + +public class ImageStoreIntegrationTests : IClassFixture>, IAsyncLifetime +{ + private readonly IImageStore _imageStore; + private readonly Image _image = new ( + ContentType: "image/jpg", + Content: new MemoryStream(Encoding.UTF8.GetBytes("This is a mock image file content"))); + private string _imageId; + + public ImageStoreIntegrationTests(WebApplicationFactory factory) + { + _imageStore = factory.Services.GetRequiredService(); + } + + [Fact] + public async Task UploadDownloadImage_Success() + { + _imageId = await _imageStore.UploadImage(_image); + var downloadedImage = await _imageStore.DownloadImage(_imageId); + + Assert.Equal(_image.ContentType, downloadedImage.ContentType); + Assert.True(_image.Content.ToArray().SequenceEqual(downloadedImage.Content.ToArray())); + } + + // [Fact] + // public async Task UploadImage_WrongContentType() + // { + // var notImage = new Image( + // ContentType: "text/plain", + // Content: new MemoryStream(Encoding.UTF8.GetBytes("This is a mock file simulating an invalid image type"))); + // await Assert.ThrowsAsync(async () => await _imageStore.UploadImage(notImage)); + // } + + [Fact] + public async Task DownloadImage_NonExistingImage() + { + var downloadedImage = await _imageStore.DownloadImage("dummy_id"); + Assert.Null(downloadedImage); + } + + [Fact] + public async Task DeleteImage_Success() + { + _imageId = await _imageStore.UploadImage(_image); + var imageDeleted = await _imageStore.DeleteImage(_imageId); + var downloadedImage = await _imageStore.DownloadImage(_imageId); + + Assert.True(imageDeleted); + Assert.Null(downloadedImage); + } + + [Fact] + public async Task DeleteImage_NonExistingImage() + { + Assert.False(await _imageStore.DeleteImage("dummy_id")); + } + + [Fact] + public async Task ImageExists_Exists() + { + _imageId = await _imageStore.UploadImage(_image); + Assert.True(await _imageStore.ImageExists(_imageId)); + } + + [Fact] + public async Task ImageExists_DoesntExist() + { + Assert.False(await _imageStore.ImageExists("dummy_id")); + } + + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + await _imageStore.DeleteImage(_imageId); + } +} \ No newline at end of file diff --git a/ChatService.Web.IntegrationTests/Usings.cs b/ChatService.Web.IntegrationTests/Usings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/ChatService.Web.IntegrationTests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/ChatService.Web.Tests/ChatService.Web.Tests.csproj b/ChatService.Web.Tests/ChatService.Web.Tests.csproj new file mode 100644 index 0000000..d2ff97f --- /dev/null +++ b/ChatService.Web.Tests/ChatService.Web.Tests.csproj @@ -0,0 +1,32 @@ + + + + net6.0 + enable + enable + + false + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/ChatService.Web.Tests/Controllers/ConversationsControllerTests.cs b/ChatService.Web.Tests/Controllers/ConversationsControllerTests.cs new file mode 100644 index 0000000..1fda9ce --- /dev/null +++ b/ChatService.Web.Tests/Controllers/ConversationsControllerTests.cs @@ -0,0 +1,491 @@ +using System.Net; +using System.Net.Http.Json; +using ChatService.Web.Dtos; +using ChatService.Web.Enums; +using ChatService.Web.Exceptions; +using ChatService.Web.Services; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Newtonsoft.Json; + +namespace ChatService.Web.Tests.Controllers; + +public class ConversationsControllerTests : IClassFixture> +{ + private readonly HttpClient _httpClient; + private readonly Mock _userConversationServiceMock = new(); + private readonly Mock _messageServiceMock = new(); + + private static readonly string _username = Guid.NewGuid().ToString(); + private readonly string _conversationId = Guid.NewGuid().ToString(); + private readonly static long _unixTimeNow = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + private readonly static string _nextContinuationToken = Guid.NewGuid().ToString(); + private readonly string _nonUrlCharactersContinuationToken = "+\"#%&+/?:"; + + private GetMessagesParameters _getMessagesParameters = new() + { + Limit = 50, + Order = OrderBy.DESC, + ContinuationToken = null, + LastSeenMessageTime = 0 + }; + + private GetUserConversationsParameters _getUserConversationsParameters = new() + { + Limit = 10, + Order = OrderBy.DESC, + ContinuationToken = null, + LastSeenConversationTime = 0 + }; + + private static readonly SendMessageRequest _sendMessageRequest = new() + { + Id = Guid.NewGuid().ToString(), + SenderUsername = _username, + Text = "Hello" + }; + + private readonly StartConversationRequest _startConversationRequest = new() + { + Participants = new List { _username, Guid.NewGuid().ToString() }, + FirstMessage = _sendMessageRequest + }; + + private readonly GetConversationsResult _getConversationsResult = new() + { + Conversations = new List{ CreateConversation(), CreateConversation() }, + NextContinuationToken = _nextContinuationToken + }; + + public ConversationsControllerTests(WebApplicationFactory factory) + { + _httpClient = factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddSingleton(_userConversationServiceMock.Object); + services.AddSingleton(_messageServiceMock.Object); + }); + }).CreateClient(); + } + + [Fact] + public async Task GetUserConversations_Success() + { + _userConversationServiceMock.Setup(m => m.GetUserConversations(_username, _getUserConversationsParameters)) + .ReturnsAsync(_getConversationsResult); + + string nextUri = "/api/conversations" + + $"?username={_username}" + + $"&limit={_getUserConversationsParameters.Limit}" + + $"&lastSeenConversationTime={_getUserConversationsParameters.LastSeenConversationTime}" + + $"&continuationToken={WebUtility.UrlEncode(_nextContinuationToken)}"; + + var response = await _httpClient.GetAsync($"api/Conversations/?username={_username}"); + var json = await response.Content.ReadAsStringAsync(); + var receivedGetUserConversationsResponse = JsonConvert.DeserializeObject(json); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(_getConversationsResult.Conversations, receivedGetUserConversationsResponse.Conversations); + Assert.Equal(nextUri, receivedGetUserConversationsResponse.NextUri); + } + + [Theory] + [InlineData(null, "1", "1")] + [InlineData("", "1", "1")] + [InlineData(" ", "1", "1")] + [InlineData("foobar", null, "1")] + [InlineData("foobar", "", "1")] + [InlineData("foobar", " ", "1")] + [InlineData("foobar", "NaN", "1")] + [InlineData("foobar", "1", null)] + [InlineData("foobar", "1", "")] + [InlineData("foobar", "1", " ")] + [InlineData("foobar", "1", "NaN")] + public async Task GetUserConversations_InvalidQueryParameters(string username, string limit, string lastSeenConversationTime) + { + var response = await _httpClient.GetAsync("/api/conversations" + + $"?username={username}" + + $"&limit={limit}" + + $"&lastSeenConversationTime={lastSeenConversationTime}"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GetUserConversations_ContinuationTokenEncode() + { + _getUserConversationsParameters.ContinuationToken = null; + _getConversationsResult.NextContinuationToken = _nonUrlCharactersContinuationToken; + _userConversationServiceMock.Setup( + m => m.GetUserConversations(_username, _getUserConversationsParameters)) + .ReturnsAsync(_getConversationsResult); + + var response = await _httpClient.GetAsync("/api/conversations" + + $"?username={_username}" + + $"&limit={_getUserConversationsParameters.Limit}" + + $"&lastSeenConversationTime={_getUserConversationsParameters.LastSeenConversationTime}" + + $"&continuationToken={_getUserConversationsParameters.ContinuationToken}"); + var json = await response.Content.ReadAsStringAsync(); + var receivedGetUserConversationsResponse = JsonConvert.DeserializeObject(json); + + string expectedEncodedNextUri = "/api/conversations" + + $"?username={_username}" + + $"&limit={_getUserConversationsParameters.Limit}" + + $"&lastSeenConversationTime={_getUserConversationsParameters.LastSeenConversationTime}" + + $"&continuationToken={WebUtility.UrlEncode(_getConversationsResult.NextContinuationToken)}"; + + Assert.Equal(expectedEncodedNextUri, receivedGetUserConversationsResponse.NextUri); + } + + [Fact] + public async Task GetUserConversations_ContinuationTokenDecode() + { + _getUserConversationsParameters.ContinuationToken = _nonUrlCharactersContinuationToken; + _userConversationServiceMock.Setup( + m => m.GetUserConversations(_username, _getUserConversationsParameters)) + .ReturnsAsync(_getConversationsResult); + + var response = await _httpClient.GetAsync("/api/conversations" + + $"?username={_username}" + + $"&limit={_getUserConversationsParameters.Limit}" + + $"&lastSeenConversationTime={_getUserConversationsParameters.LastSeenConversationTime}" + + $"&continuationToken={WebUtility.UrlEncode(_nonUrlCharactersContinuationToken)}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + _userConversationServiceMock.Verify( + m => m.GetUserConversations(_username, _getUserConversationsParameters), Times.Once); + } + + [Fact] + public async Task GetUserConversations_InvalidArguments() + { + _userConversationServiceMock.Setup(m => m.GetUserConversations(_username, _getUserConversationsParameters)) + .ThrowsAsync(new ArgumentException($"Invalid arguments.")); + + var response = await _httpClient.GetAsync($"api/Conversations/?username={_username}"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GetUserConversations_InvalidContinuationToken() + { + string invalidContinuationToken = Guid.NewGuid().ToString(); + _getUserConversationsParameters.ContinuationToken = invalidContinuationToken; + + _userConversationServiceMock.Setup(m => m.GetUserConversations( + _username, _getUserConversationsParameters)) + .ThrowsAsync(new InvalidContinuationTokenException($"Continuation token {invalidContinuationToken} is invalid.")); + + var response = await _httpClient.GetAsync( + $"api/Conversations/?username={_username}&continuationToken={invalidContinuationToken}"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GetUserConversations_UserNotFound() + { + _userConversationServiceMock.Setup(m => m.GetUserConversations( + _username, _getUserConversationsParameters)) + .ThrowsAsync(new UserNotFoundException($"A user with the username {_username} was not found.")); + + var response = await _httpClient.GetAsync( + $"api/Conversations/?username={_username}&"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetUserConversations_CosmosServiceUnavailable() + { + _userConversationServiceMock.Setup(m => m.GetUserConversations( + _username, _getUserConversationsParameters)) + .ThrowsAsync(new CosmosServiceUnavailableException("Cosmos service is unavailable.")); + + var response = await _httpClient.GetAsync( + $"api/Conversations/?username={_username}&"); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + } + + [Fact] + public async Task StartConversation_Success() + { + var startConversationServiceResult = new StartConversationResult + { + ConversationId = Guid.NewGuid().ToString(), + CreatedUnixTime = _unixTimeNow + }; + + _userConversationServiceMock.Setup(m => m.StartConversation(It.Is( + p => p.Participants.SequenceEqual(_startConversationRequest.Participants) + && p.FirstMessage == _startConversationRequest.FirstMessage))) + .ReturnsAsync(startConversationServiceResult); + + var expectedStartConversationResponse = new StartConversationResponse + { + Id = startConversationServiceResult.ConversationId, + CreatedUnixTime = startConversationServiceResult.CreatedUnixTime + }; + + var response = await _httpClient.PostAsJsonAsync($"api/Conversations/", _startConversationRequest); + var json = await response.Content.ReadAsStringAsync(); + var receivedStartConversationResponse = JsonConvert.DeserializeObject(json); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.Equal(expectedStartConversationResponse, receivedStartConversationResponse); + } + + [Fact] + public async Task StartConversation_InvalidArguments() + { + _userConversationServiceMock.Setup(m => m.StartConversation(It.Is( + p => p.Participants.SequenceEqual(_startConversationRequest.Participants) + && p.FirstMessage == _startConversationRequest.FirstMessage))) + .ThrowsAsync(new ArgumentException()); + + var response = await _httpClient.PostAsJsonAsync($"api/Conversations/", _startConversationRequest); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task StartConversation_ProfileNotFound() + { + _userConversationServiceMock.Setup(m => m.StartConversation(It.Is( + p => p.Participants.SequenceEqual(_startConversationRequest.Participants) + && p.FirstMessage == _startConversationRequest.FirstMessage))) + .ThrowsAsync(new UserNotFoundException($"A user with the username {_username} was not found.")); + + var response = await _httpClient.PostAsJsonAsync($"api/Conversations/", _startConversationRequest); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task StartConversation_MessageExists() + { + _userConversationServiceMock.Setup(m => m.StartConversation(It.Is( + p => p.Participants.SequenceEqual(_startConversationRequest.Participants) + && p.FirstMessage == _startConversationRequest.FirstMessage))) + .ThrowsAsync(new MessageExistsException( + $"A message with ID {_startConversationRequest.FirstMessage.Id} already exists.")); + + var response = await _httpClient.PostAsJsonAsync($"api/Conversations/", _startConversationRequest); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + [Fact] + public async Task StartConversation_CosmosServiceUnavailable() + { + _userConversationServiceMock.Setup(m => m.StartConversation(It.Is( + p => p.Participants.SequenceEqual(_startConversationRequest.Participants) + && p.FirstMessage == _startConversationRequest.FirstMessage))) + .ThrowsAsync(new CosmosServiceUnavailableException("Cosmos service is unavailable.")); + + var response = await _httpClient.PostAsJsonAsync($"api/Conversations/", _startConversationRequest); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + } + + [Fact] + public async Task GetMessages_Success() + { + List messages = new(); + messages.Add(new Message + { + Id = Guid.NewGuid().ToString(), + SenderUsername = Guid.NewGuid().ToString(), + UnixTime = _unixTimeNow + }); + messages.Add(new Message + { + Id = Guid.NewGuid().ToString(), + SenderUsername = Guid.NewGuid().ToString(), + UnixTime = _unixTimeNow + }); + + var getMessagesServiceResult = new GetMessagesResult + { + Messages = messages, + NextContinuationToken = _nextContinuationToken + }; + + _messageServiceMock.Setup(m => m.GetMessages(_conversationId, _getMessagesParameters)) + .ReturnsAsync(getMessagesServiceResult); + + string nextUri = $"/api/conversations/{_conversationId}/messages" + + $"?limit={_getMessagesParameters.Limit}" + + $"&continuationToken={WebUtility.UrlEncode(_nextContinuationToken)}" + + $"&lastSeenConversationTime={_getUserConversationsParameters.LastSeenConversationTime}"; + + var response = await _httpClient.GetAsync($"/api/conversations/{_conversationId}/messages/"); + var json = await response.Content.ReadAsStringAsync(); + var receivedGetMessagesResponse = JsonConvert.DeserializeObject(json); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(messages, receivedGetMessagesResponse.Messages); + Assert.Equal(nextUri, receivedGetMessagesResponse.NextUri); + } + + [Theory] + [InlineData(null, "1")] + [InlineData("", "1")] + [InlineData(" ", "1")] + [InlineData("Nan","1")] + [InlineData("1", null)] + [InlineData("1", "")] + [InlineData("1", " ")] + [InlineData("1", "NaN")] + public async Task GetMessages_InvalidQueryParameters(string limit, string lastSeenMessageTime) + { + var response = await _httpClient.GetAsync($"/api/conversations/{_conversationId}/messages/" + + $"?limit={limit}" + + $"&lastSeenMessageTime={lastSeenMessageTime}"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GetMessages_InvalidArguments() + { + _messageServiceMock.Setup(m => m.GetMessages(_conversationId, _getMessagesParameters)) + .ThrowsAsync(new ArgumentException()); + + var response = await _httpClient.GetAsync($"/api/conversations/{_conversationId}/messages/"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GetMessages_ConversationDoesNotExist() + { + _messageServiceMock.Setup(m => m.GetMessages(_conversationId, _getMessagesParameters)) + .ThrowsAsync(new ConversationDoesNotExistException( + $"A conversation partition with the conversationId {_conversationId} does not exist.")); + + var response = await _httpClient.GetAsync($"/api/conversations/{_conversationId}/messages/"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetMessages_CosmosServiceUnavailable() + { + _messageServiceMock.Setup(m => m.GetMessages(_conversationId, _getMessagesParameters)) + .ThrowsAsync(new CosmosServiceUnavailableException("Cosmos service is unavailable.")); + + var response = await _httpClient.GetAsync($"/api/conversations/{_conversationId}/messages/"); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + } + + [Fact] + public async Task PostMessage_Success() + { + var sendMessageResponse = new SendMessageResponse + { + CreatedUnixTime = _unixTimeNow + }; + + _messageServiceMock.Setup(m => m.AddMessage(_conversationId, false, _sendMessageRequest)) + .ReturnsAsync(sendMessageResponse); + + var response = await _httpClient.PostAsJsonAsync( + $"/api/conversations/{_conversationId}/messages/", _sendMessageRequest); + var json = await response.Content.ReadAsStringAsync(); + var receivedSendMessageResponse = JsonConvert.DeserializeObject(json); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.Equal(sendMessageResponse, receivedSendMessageResponse); + } + + [Fact] + public async Task PostMessage_InvalidArguments() + { + _messageServiceMock.Setup(m => m.AddMessage(_conversationId, false, _sendMessageRequest)) + .ThrowsAsync(new ArgumentException()); + + var response = await _httpClient.PostAsJsonAsync( + $"/api/conversations/{_conversationId}/messages/", _sendMessageRequest); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PostMessage_UserNotParticipant() + { + _messageServiceMock.Setup(m => m.AddMessage(_conversationId, false, _sendMessageRequest)) + .ThrowsAsync(new UserNotParticipantException( + $"User {_username} is not a participant of conversation {_conversationId}.")); + + var response = await _httpClient.PostAsJsonAsync( + $"/api/conversations/{_conversationId}/messages/", _sendMessageRequest); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task PostMessage_ProfileNotFound() + { + _messageServiceMock.Setup(m => m.AddMessage(_conversationId, false, _sendMessageRequest)) + .ThrowsAsync(new UserNotFoundException( + $"A user with the username {_username} was not found.")); + + var response = await _httpClient.PostAsJsonAsync( + $"/api/conversations/{_conversationId}/messages/", _sendMessageRequest); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task PostMessage_ConversationDoesNotExist() + { + _messageServiceMock.Setup(m => m.AddMessage(_conversationId, false, _sendMessageRequest)) + .ThrowsAsync(new ConversationDoesNotExistException( + $"A conversation partition with the conversationId {_conversationId} does not exist.")); + + var response = await _httpClient.PostAsJsonAsync( + $"/api/conversations/{_conversationId}/messages/", _sendMessageRequest); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task PostMessage_MessageExists() + { + _messageServiceMock.Setup(m => m.AddMessage(_conversationId, false, _sendMessageRequest)) + .ThrowsAsync(new MessageExistsException($"A message with ID {_sendMessageRequest.Id} already exists.")); + + var response = await _httpClient.PostAsJsonAsync( + $"/api/conversations/{_conversationId}/messages/", _sendMessageRequest); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + [Fact] + public async Task PostMessage_CosmosServiceUnavailable() + { + _messageServiceMock.Setup(m => m.AddMessage(_conversationId, false, _sendMessageRequest)) + .ThrowsAsync(new CosmosServiceUnavailableException("Cosmos service is unavailable.")); + + var response = await _httpClient.PostAsJsonAsync( + $"/api/conversations/{_conversationId}/messages/", _sendMessageRequest); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + } + + private static Conversation CreateConversation() + { + return new Conversation + { + Id = Guid.NewGuid().ToString(), + LastModifiedUnixTime = _unixTimeNow + }; + } +} \ No newline at end of file diff --git a/ChatService.Web.Tests/Controllers/ImagesControllerTests.cs b/ChatService.Web.Tests/Controllers/ImagesControllerTests.cs new file mode 100644 index 0000000..b5c397c --- /dev/null +++ b/ChatService.Web.Tests/Controllers/ImagesControllerTests.cs @@ -0,0 +1,146 @@ +using System.Net; +using System.Net.Http.Headers; +using ChatService.Web.Dtos; +using ChatService.Web.Exceptions; +using ChatService.Web.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Newtonsoft.Json; + +namespace ChatService.Web.Tests.Controllers; + +public class ImagesControllerTests : IClassFixture> +{ + private readonly Mock _imageServiceMock = new(); + private readonly HttpClient _httpClient; + + private static readonly Image _image = new(ContentType: "image/jpeg", Content: new MemoryStream()); + + private readonly MultipartFormDataContent _content = new(); + + private readonly StreamContent _fileContent = new(_image.Content) + { + Headers = { ContentType = new MediaTypeHeaderValue(_image.ContentType) } + }; + + private readonly string _imageId = Guid.NewGuid().ToString(); + + public ImagesControllerTests(WebApplicationFactory factory) + { + _httpClient = factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => { services.AddSingleton(_imageServiceMock.Object); }); + }).CreateClient(); + } + + [Fact] + public async Task UploadImage_Success() + { + var uploadImageResponse = new UploadImageResponse(_imageId); + + _imageServiceMock.Setup(m => m.UploadImage(It.IsAny())) + .ReturnsAsync(new UploadImageResult(_imageId)); + + _content.Add(_fileContent,"File", "image.jpeg"); + + var response = await _httpClient.PostAsync("api/Images/", _content); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + var json = await response.Content.ReadAsStringAsync(); + var receivedUploadImageResponse = JsonConvert.DeserializeObject(json); + + Assert.Equal(uploadImageResponse, receivedUploadImageResponse); + } + + [Fact] + public async Task UploadImage_MissingFile() + { + var response = await _httpClient.PostAsync("api/Images/", _content); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + _imageServiceMock.Verify( m => m.UploadImage(It.IsAny()), Times.Never); + } + + [Fact] + public async Task UploadImage_InvalidImageType() + { + _imageServiceMock.Setup(m => m.UploadImage(It.IsAny())) + .ThrowsAsync(new InvalidImageTypeException($"Invalid image type {_image.ContentType}.")); + + _content.Add(_fileContent,"File", "text/plain"); + + var response = await _httpClient.PostAsync("api/Images/", _content); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task UploadImage_BlobServiceUnavailable() + { + _imageServiceMock.Setup(m => m.UploadImage(It.IsAny())) + .ThrowsAsync(new BlobServiceUnavailableException("Blob service is unavailable.")); + _content.Add(_fileContent,"File", "image.jpeg"); + + var response = await _httpClient.PostAsync("api/Images/", _content); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + } + + [Fact] + public async Task DownloadImage_Success() + { + var fileContentResult = new FileContentResult(_image.Content.ToArray(), _image.ContentType); + + _imageServiceMock.Setup(m => m.DownloadImage(_imageId)) + .ReturnsAsync(_image); + + var response = await _httpClient.GetAsync($"api/Images/{_imageId}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content.Headers.ContentType); + + var contentType = response.Content.Headers.ContentType.ToString(); + var content = await response.Content.ReadAsByteArrayAsync(); + + Assert.Equal(fileContentResult.FileContents, content); + Assert.Equal(fileContentResult.ContentType, contentType); + } + + [Fact] + public async Task DownloadImage_NotFound() + { + _imageServiceMock.Setup(m => m.DownloadImage(_imageId)) + .ThrowsAsync( new ImageNotFoundException($"An image with id {_imageId} was not found.")); + + var response = await _httpClient.GetAsync($"api/Images/{_imageId}"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task DownloadImage_InvalidArgument() + { + _imageServiceMock.Setup(m => m.DownloadImage(_imageId)) + .ThrowsAsync( new ArgumentException("Invalid imageId")); + + var response = await _httpClient.GetAsync($"api/Images/{_imageId}"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task DownloadImage_BlobServiceUnavailable() + { + _imageServiceMock.Setup(m => m.DownloadImage(_imageId)) + .ThrowsAsync(new BlobServiceUnavailableException("Blob service is unavailable.")); + + var response = await _httpClient.GetAsync($"api/Images/{_imageId}"); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + } +} \ No newline at end of file diff --git a/ChatService.Web.Tests/Controllers/ProfilesControllerTests.cs b/ChatService.Web.Tests/Controllers/ProfilesControllerTests.cs new file mode 100644 index 0000000..6cbe23a --- /dev/null +++ b/ChatService.Web.Tests/Controllers/ProfilesControllerTests.cs @@ -0,0 +1,170 @@ +using System.Net; +using System.Net.Http.Json; +using ChatService.Web.Dtos; +using ChatService.Web.Exceptions; +using ChatService.Web.Services; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Newtonsoft.Json; + +namespace ChatService.Web.Tests.Controllers; + +public class ProfilesControllerTests : IClassFixture> +{ + private readonly Mock _profileServiceMock = new(); + private readonly HttpClient _httpClient; + private readonly Profile _profile = new() + { + Username = "foobar", + FirstName = "Foo", + LastName = "Bar", + ProfilePictureId = "123" + }; + + public ProfilesControllerTests(WebApplicationFactory factory) + { + _httpClient = factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddSingleton(_profileServiceMock.Object); + }); + }).CreateClient(); + } + + [Fact] + public async Task GetProfile_Success() + { + _profileServiceMock.Setup(m => m.GetProfile(_profile.Username)) + .ReturnsAsync(_profile); + + var response = await _httpClient.GetAsync($"api/Profile/{_profile.Username}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var json = await response.Content.ReadAsStringAsync(); + var receivedProfile = JsonConvert.DeserializeObject(json); + Assert.Equal(_profile, receivedProfile); + } + + [Fact] + public async Task GetProfile_ProfileNotFound() + { + _profileServiceMock.Setup(m => m.GetProfile(_profile.Username)) + .ThrowsAsync(new UserNotFoundException($"A user with the username {_profile.Username} was not found.")); + + var response = await _httpClient.GetAsync($"api/Profile/{_profile.Username}"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetProfile_CosmosServiceUnavailable() + { + _profileServiceMock.Setup(m => m.GetProfile(_profile.Username)) + .ThrowsAsync(new CosmosServiceUnavailableException("Cosmos service is unavailable.")); + + var response = await _httpClient.GetAsync($"api/Profile/{_profile.Username}"); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + } + + [Fact] + public async Task PostProfile_Success() + { + var response = await _httpClient.PostAsJsonAsync("api/Profile/", _profile); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + var json = await response.Content.ReadAsStringAsync(); + var receivedProfile = JsonConvert.DeserializeObject(json); + Assert.Equal(_profile, receivedProfile); + + _profileServiceMock.Verify(mock => mock.AddProfile(_profile), Times.Once); + } + + [Fact] + public async Task PostProfile_UsernameTaken() + { + _profileServiceMock.Setup(m => m.AddProfile(_profile)) + .ThrowsAsync(new UsernameTakenException($"The username {_profile.Username} is taken.")); + + var response = await _httpClient.PostAsJsonAsync("api/Profile/", _profile); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + [Theory] + [InlineData(null, "Foo", "Bar", "123")] + [InlineData("", "Foo", "Bar", "123")] + [InlineData(" ", "Foo", "Bar", "123")] + [InlineData("foobar", null, "Bar", "123")] + [InlineData("foobar", "", "Bar", "123")] + [InlineData("foobar", " ", "Bar", "123")] + [InlineData("foobar", "Foo", null, "123")] + [InlineData("foobar", "Foo", "", "123")] + [InlineData("foobar", "Foo", " ", "123")] + // [InlineData("foobar", "Foo", "Bar", null)] + // [InlineData("foobar", "Foo", "Bar", "")] + // [InlineData("foobar", "Foo", "Bar", " ")] + public async Task PostProfile_InvalidArguments(string username, string firstName, string lastName, string profilePictureId) + { + _profileServiceMock.Setup(m => m.AddProfile(_profile)) + .ThrowsAsync(new ArgumentException($"Invalid profile {_profile}")); + + Profile profile = new() + { + Username = username, + FirstName = firstName, + LastName = lastName, + ProfilePictureId = profilePictureId + }; + + var response = await _httpClient.PostAsJsonAsync("api/Profile/", profile); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PostProfile_InvalidUsername() + { + Profile profile = new() + { + Username = "username_with_underscore", + FirstName = "firstName", + LastName = "lastName", + ProfilePictureId = "profilePictureId" + }; + + _profileServiceMock.Setup(m => m.AddProfile(profile)) + .ThrowsAsync(new InvalidUsernameException($"Username {profile.Username} is invalid. Usernames cannot have an underscore.")); + + var response = await _httpClient.PostAsJsonAsync("api/Profile/", profile); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task ProfileProfile_ProfilePictureNotFound() + { + _profileServiceMock.Setup(m => m.AddProfile(_profile)) + .ThrowsAsync(new ImageNotFoundException("Invalid profile picture ID.")); + + var response = await _httpClient.PostAsJsonAsync("api/Profile/", _profile); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task ProfileProfile_CosmosServiceUnavailable() + { + _profileServiceMock.Setup(m => m.AddProfile(_profile)) + .ThrowsAsync(new CosmosServiceUnavailableException("Cosmos service is unavailable.")); + + var response = await _httpClient.PostAsJsonAsync("api/Profile/", _profile); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + } +} \ No newline at end of file diff --git a/ChatService.Web.Tests/Services/ImageServiceTests.cs b/ChatService.Web.Tests/Services/ImageServiceTests.cs new file mode 100644 index 0000000..b2969ac --- /dev/null +++ b/ChatService.Web.Tests/Services/ImageServiceTests.cs @@ -0,0 +1,87 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Exceptions; +using ChatService.Web.Services; +using ChatService.Web.Storage; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace ChatService.Web.Tests.Services; + +public class ImageServiceTests : IClassFixture> +{ + private readonly Mock _imageStoreMock = new(); + private readonly IImageService _imageService; + + private readonly string _imageId = Guid.NewGuid().ToString(); + private readonly Image _image = new( + ContentType: "image/jpeg", + Content: new MemoryStream(new byte[] { 0x01, 0x02, 0x03 })); + + public ImageServiceTests(WebApplicationFactory factory) + { + _imageService = factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddSingleton(_imageStoreMock.Object); + }); + }).Services.GetRequiredService(); + } + + [Fact] + public async Task UploadImage_Success() + { + _imageStoreMock.Setup(m => m.UploadImage(It.IsAny())) + .ReturnsAsync(_imageId); + + var expectedUploadImageServiceResult = new UploadImageResult(_imageId); + + var receivedUploadImageServiceResult = await _imageService.UploadImage(_image); + + Assert.Equal(expectedUploadImageServiceResult, receivedUploadImageServiceResult); + } + + // [Fact] + // public async Task UploadImage_InvalidImageType() + // { + // var invalidImage = new Image(ContentType: "text/plain", Content: new MemoryStream()); + // + // await Assert.ThrowsAsync(() => _imageService.UploadImage(invalidImage)); + // + // _imageStoreMock.Verify(m => m.UploadImage(It.IsAny()), Times.Never); + // } + + [Fact] + public async Task DownloadImage_Success() + { + _imageStoreMock.Setup(m => m.DownloadImage(_imageId)) + .ReturnsAsync(_image); + + var receivedImage = await _imageService.DownloadImage(_imageId); + + Assert.Equal(_image.ContentType, receivedImage.ContentType); + Assert.True(_image.Content.ToArray().SequenceEqual(receivedImage.Content.ToArray())); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task DownloadImage_InvalidArguments(string imageId) + { + + await Assert.ThrowsAsync(() => _imageService.DownloadImage(imageId)); + } + + [Fact] + public async Task DownloadImage_ImageNotFound() + { + _imageStoreMock.Setup(m => m.DownloadImage(_imageId)) + .ReturnsAsync((Image?)null); + + await Assert.ThrowsAsync(() => _imageService.DownloadImage(_imageId)); + } +} \ No newline at end of file diff --git a/ChatService.Web.Tests/Services/MessageServiceTests.cs b/ChatService.Web.Tests/Services/MessageServiceTests.cs new file mode 100644 index 0000000..935e33c --- /dev/null +++ b/ChatService.Web.Tests/Services/MessageServiceTests.cs @@ -0,0 +1,259 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Enums; +using ChatService.Web.Exceptions; +using ChatService.Web.Services; +using ChatService.Web.Storage; +using ChatService.Web.Utilities; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace ChatService.Web.Tests.Services; + +public class MessageServiceTests : IClassFixture> +{ + private readonly Mock _messageStoreMock = new(); + private readonly Mock _userConversationStoreMock = new(); + private readonly Mock _profileServiceMock = new(); + private readonly IMessageService _messageService; + + private GetMessagesParameters _parameters = new() + { + Limit = 1, + Order = OrderBy.ASC, + ContinuationToken = null, + LastSeenMessageTime = 0 + }; + + private static readonly string _senderUsername = Guid.NewGuid().ToString(); + private static readonly string _recipientUsername = Guid.NewGuid().ToString(); + private static readonly string _conversationId = + ConversationIdUtilities.GenerateConversationId(_senderUsername, _recipientUsername); + + private readonly long _unixTimeNow = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + private readonly SendMessageRequest _sendMessageRequest = new() + { + Id = Guid.NewGuid().ToString(), + SenderUsername = _senderUsername, + Text = Guid.NewGuid().ToString() + }; + + public MessageServiceTests(WebApplicationFactory factory) + { + _messageService = factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddSingleton(_messageStoreMock.Object); + services.AddSingleton(_userConversationStoreMock.Object); + services.AddSingleton(_profileServiceMock.Object); + }); + }).Services.GetRequiredService(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task AddMessage_Success(bool isFirstMessage) + { + _profileServiceMock.Setup(m => m.ProfileExists(_senderUsername)) + .ReturnsAsync(true); + + _messageStoreMock.Setup(m => m.ConversationExists(_conversationId)) + .ReturnsAsync(true); + + Message message = new() + { + Id = _sendMessageRequest.Id, + UnixTime = _unixTimeNow, + SenderUsername = _sendMessageRequest.SenderUsername, + Text = _sendMessageRequest.Text + }; + + SendMessageResponse expectedSendMessageResponse = new() + { + CreatedUnixTime = _unixTimeNow + }; + + SendMessageResponse receivedSendMessageResponse = await _messageService.AddMessage( + _conversationId, isFirstMessage, _sendMessageRequest); + + _messageStoreMock.Verify(m => m.AddMessage(_conversationId, It.Is( + m => m.Id == message.Id + && m.SenderUsername == message.SenderUsername + && m.Text == message.Text)), Times.Once); + + List userConversations = CreateUserConversations(_conversationId, _unixTimeNow); + String username1 = userConversations.ElementAt(0).Username; + String username2 = userConversations.ElementAt(1).Username; + + _userConversationStoreMock.Verify(m => + m.UpsertUserConversation(It.Is(userConversation => + userConversation.Username == username1 && userConversation.ConversationId == _conversationId)), + Times.Once); + + _userConversationStoreMock.Verify(m => + m.UpsertUserConversation(It.Is(userConversation => + userConversation.Username == username2 && userConversation.ConversationId == _conversationId)), + Times.Once); + + receivedSendMessageResponse.CreatedUnixTime = _unixTimeNow; + + Assert.Equal(expectedSendMessageResponse, receivedSendMessageResponse); + } + + [Theory] + [InlineData(null, "messageId", "senderUsername", "text")] + [InlineData("", "messageId", "senderUsername", "text")] + [InlineData(" ", "messageId", "senderUsername", "text")] + [InlineData("conversationId", null, "senderUsername", "text")] + [InlineData("conversationId", "", "senderUsername", "text")] + [InlineData("conversationId", " ", "senderUsername", "text")] + [InlineData("conversationId", "messageId", null, "text")] + [InlineData("conversationId", "messageId", "", "text")] + [InlineData("conversationId", "messageId", " ", "text")] + [InlineData("conversationId", "messageId", "senderUsername", null)] + [InlineData("conversationId", "messageId", "senderUsername", "")] + [InlineData("conversationId", "messageId", "senderUsername", " ")] + public async Task AddMessage_InvalidArguments( + string conversationId, string messageId, string senderUsername, string text) + { + SendMessageRequest sendMessageRequest = new() + { + Id = messageId, + SenderUsername = senderUsername, + Text = text + }; + + await Assert.ThrowsAsync(() => _messageService.AddMessage( + conversationId, true, sendMessageRequest)); + } + + [Fact] + public async Task AddMessage_UserNotParticipant() + { + _sendMessageRequest.SenderUsername = Guid.NewGuid().ToString(); + + _messageStoreMock.Setup(m => m.ConversationExists(_conversationId)) + .ReturnsAsync(true); + + _profileServiceMock.Setup(m => m.ProfileExists(_sendMessageRequest.SenderUsername)) + .ReturnsAsync(true); + + await Assert.ThrowsAsync( + () => _messageService.AddMessage(_conversationId, true, _sendMessageRequest)); + } + + [Fact] + public async Task AddMessage_ProfileNotFound() + { + _profileServiceMock.Setup(m => m.ProfileExists(_senderUsername)) + .ReturnsAsync(false); + + await Assert.ThrowsAsync(() => _messageService.AddMessage( + _conversationId, true, _sendMessageRequest)); + } + + [Fact] + public async Task AddMessage_ConversationDoesNotExist() + { + _profileServiceMock.Setup(m => m.ProfileExists(_senderUsername)) + .ReturnsAsync(true); + + _messageStoreMock.Setup(m => m.ConversationExists(_conversationId)) + .ReturnsAsync(false); + + await Assert.ThrowsAsync(() => _messageService.AddMessage( + _conversationId, false, _sendMessageRequest)); + } + + [Fact] + public async Task GetMessages_Success() + { + _messageStoreMock.Setup(m => m.ConversationExists(_conversationId)) + .ReturnsAsync(true); + + List messages = new() { CreateMessage(), CreateMessage() }; + + string nextContinuationToken = Guid.NewGuid().ToString(); + + GetMessagesResult getMessagesResult = new() + { + Messages = messages, + NextContinuationToken = nextContinuationToken + }; + + _messageStoreMock.Setup(m => m.GetMessages(_conversationId, _parameters)) + .ReturnsAsync(getMessagesResult); + + GetMessagesResult expectedGetMessagesResult = new() + { + Messages = messages, + NextContinuationToken = nextContinuationToken + }; + + _parameters.Limit = 10; + _parameters.Order = OrderBy.DESC; + GetMessagesResult receivedGetMessagesResult = await _messageService.GetMessages( + _conversationId, _parameters); + + Assert.Equal(expectedGetMessagesResult.Messages, receivedGetMessagesResult.Messages); + Assert.Equal(expectedGetMessagesResult.NextContinuationToken, receivedGetMessagesResult.NextContinuationToken); + } + + [Theory] + [InlineData(null, 1, 1)] + [InlineData("", 1, 1)] + [InlineData(" ", 1, 1)] + [InlineData("conversationId", 0, 1)] + [InlineData("conversationId", -1, 1)] + [InlineData("conversationId", 1, -1)] + public async Task GetMessages_InvalidArguments(string conversationId, int limit, long lastSeenConversationTime) + { + _parameters.Limit = limit; + _parameters.LastSeenMessageTime = lastSeenConversationTime; + + await Assert.ThrowsAsync(() => _messageService.GetMessages(conversationId, _parameters)); + } + + [Fact] + public async Task GetMessages_ConversationDoesNotExist() + { + _messageStoreMock.Setup(m => m.ConversationExists(_conversationId)) + .ReturnsAsync(false); + + await Assert.ThrowsAsync(() => _messageService.GetMessages( + _conversationId, _parameters)); + } + + private Message CreateMessage() + { + return new Message() + { + Id = Guid.NewGuid().ToString(), + UnixTime = _unixTimeNow, + SenderUsername = _senderUsername, + Text = Guid.NewGuid().ToString() + }; + } + + private List CreateUserConversations(string conversationId, long unixTime) + { + string[] usernames = ConversationIdUtilities.SplitConversationId(conversationId); + List userConversations = new(); + + foreach (string username in usernames) + { + userConversations.Add(new UserConversation + { + ConversationId = conversationId, + Username = username, + LastModifiedTime = unixTime + }); + } + + return userConversations; + } +} \ No newline at end of file diff --git a/ChatService.Web.Tests/Services/ProfileServiceTests.cs b/ChatService.Web.Tests/Services/ProfileServiceTests.cs new file mode 100644 index 0000000..13771a3 --- /dev/null +++ b/ChatService.Web.Tests/Services/ProfileServiceTests.cs @@ -0,0 +1,158 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Exceptions; +using ChatService.Web.Services; +using ChatService.Web.Storage; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace ChatService.Web.Tests.Services; + +public class ProfileServiceTests : IClassFixture> +{ + private readonly Mock _profileStoreMock = new(); + private readonly Mock _imageServiceMock = new(); + private readonly IProfileService _profileService; + + private readonly Profile _profile = new() + { + Username = Guid.NewGuid().ToString(), + FirstName = Guid.NewGuid().ToString(), + LastName = Guid.NewGuid().ToString(), + ProfilePictureId = Guid.NewGuid().ToString() + }; + + public ProfileServiceTests(WebApplicationFactory factory) + { + _profileService = factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddSingleton(_profileStoreMock.Object); + services.AddSingleton(_imageServiceMock.Object); + }); + }).Services.GetRequiredService(); + } + + [Fact] + public async Task GetProfile_Success() + { + _profileStoreMock.Setup(m => m.GetProfile(_profile.Username)) + .ReturnsAsync(_profile); + + var receivedProfile = await _profileService.GetProfile(_profile.Username); + + Assert.Equal(_profile, receivedProfile); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task GetProfile_InvalidArguments(string username) + { + await Assert.ThrowsAsync(() => _profileService.GetProfile(username)); + } + + [Fact] + public async Task AddNewProfile_Success() + { + _profileStoreMock.Setup(m => m.ProfileExists(_profile.Username)) + .ReturnsAsync(false); + _imageServiceMock.Setup(m => m.ImageExists(_profile.ProfilePictureId)) + .ReturnsAsync(true); + + await _profileService.AddProfile(_profile); + + _profileStoreMock.Verify(m => m.AddProfile(_profile), Times.Once); + } + + [Fact] + public async Task AddNewProfile_NullProfile() + { + await Assert.ThrowsAsync( async () => await _profileService.AddProfile(null)); + } + + [Theory] + [InlineData(null, "Foo", "Bar", "dummy_id")] + [InlineData("", "Foo", "Bar", "dummy_id")] + [InlineData(" ", "Foo", "Bar", "dummy_id")] + [InlineData("foobar", null, "Bar", "dummy_id")] + [InlineData("foobar", "", "Bar", "dummy_id")] + [InlineData("foobar", " ", "Bar", "dummy_id")] + [InlineData("foobar", "Foo", null, "dummy_id")] + [InlineData("foobar", "Foo", "", "dummy_id")] + [InlineData("foobar", "Foo", " ", "dummy_id")] + // [InlineData("foobar", "Foo", "Bar", null)] + // [InlineData("foobar", "Foo", "Bar","")] + // [InlineData("foobar", "Foo", "Bar"," ")] + public async Task AddNewProfile_InvalidArgs(string username, string firstName, string lastName, string profilePictureId) + { + Profile profile = new() + { + Username = username, + FirstName = firstName, + LastName = lastName, + ProfilePictureId = profilePictureId + }; + + await Assert.ThrowsAsync( async () => await _profileService.AddProfile(profile)); + } + + [Fact] + public async Task AddNewProfile_UsernameTaken() + { + _imageServiceMock.Setup(m => m.ImageExists(_profile.ProfilePictureId)) + .ReturnsAsync(true); + _profileStoreMock.Setup(m => m.AddProfile(_profile)) + .ThrowsAsync(new UsernameTakenException($"A profile with username {_profile.Username} already exists.")); + + await Assert.ThrowsAsync( async () => await _profileService.AddProfile(_profile)); + } + + [Fact] + public async Task AddNewProfile_InvalidUsername() + { + Profile profile = new() + { + Username = "username_with_underscore", + FirstName = "firstName", + LastName = "lastName", + ProfilePictureId = "profilePictureId" + }; + + await Assert.ThrowsAsync( async () => await _profileService.AddProfile(profile)); + } + + // [Fact] + // public async Task AddNewProfile_ProfilePictureNotFound() + // { + // _imageServiceMock.Setup(m => m.ImageExists(_profile.ProfilePictureId)) + // .ReturnsAsync(false); + // + // await Assert.ThrowsAsync( async () => await _profileService.AddProfile(_profile)); + // } + + [Fact] + public async Task DeleteProfile_Success() + { + _profileStoreMock.Setup(m => m.GetProfile(_profile.Username)) + .ReturnsAsync(_profile); + + await _profileService.DeleteProfile(_profile.Username); + + _imageServiceMock.Verify(m => m.DeleteImage(_profile.ProfilePictureId), Times.Once); + _profileStoreMock.Verify(m => m.DeleteProfile(_profile.Username), Times.Once); + } + + [Fact] + public async Task DeleteProfile_ProfileNotFound() + { + await Assert.ThrowsAsync( + async () => await _profileService.DeleteProfile(_profile.Username)); + + _imageServiceMock.Verify(m => m.DeleteImage(_profile.ProfilePictureId), Times.Never); + _profileStoreMock.Verify(m => m.DeleteProfile(_profile.Username), Times.Never); + } +} \ No newline at end of file diff --git a/ChatService.Web.Tests/Services/UserConversationServiceTests.cs b/ChatService.Web.Tests/Services/UserConversationServiceTests.cs new file mode 100644 index 0000000..5886293 --- /dev/null +++ b/ChatService.Web.Tests/Services/UserConversationServiceTests.cs @@ -0,0 +1,274 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Enums; +using ChatService.Web.Exceptions; +using ChatService.Web.Services; +using ChatService.Web.Storage; +using ChatService.Web.Utilities; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace ChatService.Web.Tests.Services; + +public class UserConversationServiceTests : IClassFixture> +{ + private readonly Mock _messageServiceMock = new(); + private readonly Mock _userConversationStoreMock = new(); + private readonly Mock _profileServiceMock = new(); + + private readonly IUserConversationService _userConversationService; + + private static readonly long _unixTimeNow = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + private static readonly List _participants = new() + { + Guid.NewGuid().ToString(), + Guid.NewGuid().ToString() + }; + + private static readonly SendMessageRequest _sendMessageRequest = new() + { + Id = Guid.NewGuid().ToString(), + SenderUsername = _participants.ElementAt(0), + Text = Guid.NewGuid().ToString() + }; + + private GetUserConversationsParameters _parameters = new() + { + Limit = 1, + Order = OrderBy.ASC, + ContinuationToken = null, + LastSeenConversationTime = 0 + }; + + private readonly StartConversationRequest _startConversationRequest = new() + { + Participants = _participants, + FirstMessage = _sendMessageRequest + }; + + public UserConversationServiceTests(WebApplicationFactory factory) + { + _userConversationService = factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddSingleton(_messageServiceMock.Object); + services.AddSingleton(_userConversationStoreMock.Object); + services.AddSingleton(_profileServiceMock.Object); + }); + }).Services.GetRequiredService(); + } + + [Fact] + public async Task CreateConversation_Success() + { + _profileServiceMock.Setup(m => m.ProfileExists(_participants.ElementAt(0))) + .ReturnsAsync(true); + _profileServiceMock.Setup(m => m.ProfileExists(_participants.ElementAt(1))) + .ReturnsAsync(true); + + var response = await _userConversationService.StartConversation(_startConversationRequest); + + response.CreatedUnixTime = _unixTimeNow; + + StartConversationResult expected = new() + { + ConversationId = ConversationIdUtilities.GenerateConversationId( + _participants.ElementAt(0), _participants.ElementAt(1)), + CreatedUnixTime = _unixTimeNow + }; + + Assert.Equal(expected, response); + } + + [Theory] + [MemberData(nameof(GenerateInvalidParticipantsList))] + public async Task CreateConversation_InvalidParticipantsList(List participants) + { + StartConversationRequest startConversationRequest = new() + { + Participants = participants, + FirstMessage = _sendMessageRequest + }; + await Assert.ThrowsAsync( () => + _userConversationService.StartConversation(startConversationRequest)); + } + + [Theory] + [InlineData(null, "senderUsername", "Hello world.")] + [InlineData("", "senderUsername", "Hello world.")] + [InlineData(" ", "senderUsername", "Hello world.")] + [InlineData("messageId", null, "Hello world.")] + [InlineData("messageId", "", "Hello world.")] + [InlineData("messageId", " ", "Hello world.")] + [InlineData("messageId", "senderUsername", null)] + [InlineData("messageId", "senderUsername", "")] + [InlineData("messageId", "senderUsername", " ")] + public async Task CreateConversation_InvalidSendMessageRequest(string messageId, string senderUsername, string text) + { + SendMessageRequest sendMessageRequest = new() + { + Id = messageId, + SenderUsername = senderUsername, + Text = text + }; + + _startConversationRequest.FirstMessage = sendMessageRequest; + + await Assert.ThrowsAsync( () => + _userConversationService.StartConversation(_startConversationRequest)); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + public async Task CreateConversation_ParticipantsNotFound(bool participant1Exists, bool participant2Exists) + { + _profileServiceMock.Setup(m => m.ProfileExists(_participants.ElementAt(0))) + .ReturnsAsync(participant1Exists); + _profileServiceMock.Setup(m => m.ProfileExists(_participants.ElementAt(1))) + .ReturnsAsync(participant2Exists); + + await Assert.ThrowsAsync( () => + _userConversationService.StartConversation(_startConversationRequest)); + } + + [Fact] + public async Task GetUserConversations_Success() + { + string username1 = Guid.NewGuid().ToString(); + _profileServiceMock.Setup(m => m.ProfileExists(username1)) + .ReturnsAsync(true); + + string username2 = Guid.NewGuid().ToString(); + string username3 = Guid.NewGuid().ToString(); + Profile profile2 = CreateProfile(username2); + Profile profile3 = CreateProfile(username3); + + List userConversations = new() + { + CreateUserConversation(senderUsername: username1, recipientUsername: username2), + CreateUserConversation(senderUsername: username1, recipientUsername: username3) + }; + + GetUserConversationsParameters parameters = new() + { + Limit = 10, + Order = OrderBy.DESC, + ContinuationToken = null, + LastSeenConversationTime = 0 + }; + + string nextContinuationToken = Guid.NewGuid().ToString(); + GetUserConversationsResult result = new() + { + UserConversations = userConversations, + NextContinuationToken = nextContinuationToken + }; + + _userConversationStoreMock.Setup(m => m.GetUserConversations(username1, parameters)) + .ReturnsAsync(result); + _profileServiceMock.Setup(m => m.GetProfile(username2)) + .ReturnsAsync(profile2); + _profileServiceMock.Setup(m => m.GetProfile(username3)) + .ReturnsAsync(profile3); + + List conversations = new() + { + CreateConversation(senderUsername: username1, recipientProfile: profile2), + CreateConversation(senderUsername: username1, recipientProfile: profile3) + }; + + GetConversationsResult expected = new() + { + Conversations = conversations, + NextContinuationToken = nextContinuationToken + }; + + _parameters.Limit = 10; + _parameters.Order = OrderBy.DESC; + var response = await _userConversationService.GetUserConversations(username1, _parameters); + + Assert.Equal(expected.Conversations, response.Conversations); + Assert.Equal(expected.NextContinuationToken, response.NextContinuationToken); + } + + [Theory] + [InlineData(null, 10, OrderBy.DESC, null, 0)] + [InlineData("", 10, OrderBy.DESC, null, 0)] + [InlineData(" ", 10, OrderBy.DESC, null, 0)] + [InlineData("username", 0, OrderBy.DESC, null, 0)] + [InlineData("username", -1, OrderBy.DESC, null, 0)] + [InlineData("username", 10, OrderBy.DESC, null, -1)] + public async Task GetUserConversations_InvalidArguments( + string username, int limit, OrderBy orderBy, string? continuationToken, long lastSeenConversationTime) + { + _parameters.Limit = limit; + _parameters.Order = orderBy; + _parameters.ContinuationToken = continuationToken; + _parameters.LastSeenConversationTime = lastSeenConversationTime; + await Assert.ThrowsAsync( () => + _userConversationService.GetUserConversations(username, _parameters)); + } + + [Fact] + public async Task GetUserConversations_UserNotFound() + { + _profileServiceMock.Setup(m => m.ProfileExists(_participants.ElementAt(0))) + .ReturnsAsync(false); + + await Assert.ThrowsAsync( () => + _userConversationService.GetUserConversations(_participants.ElementAt(0), _parameters)); + } + + public static IEnumerable GenerateInvalidParticipantsList(){ + + yield return new object[] { new List {_participants.ElementAt(0), ""} }; + yield return new object[] { new List {_participants.ElementAt(0), " "} }; + + yield return new object[] { new List { "", _participants.ElementAt(1) } }; + yield return new object[] { new List { " ", _participants.ElementAt(1) } }; + + yield return new object[] { new List + { + _participants.ElementAt(0), + _participants.ElementAt(0) + } }; + + yield return new object[] { new List { _participants.ElementAt(0) } }; + } + + private Conversation CreateConversation(string senderUsername, Profile recipientProfile) + { + return new Conversation + { + Id = ConversationIdUtilities.GenerateConversationId(senderUsername, recipientProfile.Username), + LastModifiedUnixTime = _unixTimeNow, + Recipient = recipientProfile + }; + } + + private UserConversation CreateUserConversation(string senderUsername, string recipientUsername) + { + return new UserConversation + { + Username = senderUsername, + ConversationId = ConversationIdUtilities.GenerateConversationId(senderUsername, recipientUsername), + LastModifiedTime = _unixTimeNow + }; + } + + private Profile CreateProfile(string username) + { + return new Profile + { + Username = username, + FirstName = Guid.NewGuid().ToString(), + LastName = Guid.NewGuid().ToString(), + ProfilePictureId = Guid.NewGuid().ToString() + }; + } +} \ No newline at end of file diff --git a/ChatService.Web.Tests/Usings.cs b/ChatService.Web.Tests/Usings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/ChatService.Web.Tests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/ChatService.Web/ChatService.Web.csproj b/ChatService.Web/ChatService.Web.csproj new file mode 100644 index 0000000..339ae2e --- /dev/null +++ b/ChatService.Web/ChatService.Web.csproj @@ -0,0 +1,17 @@ + + + + net6.0 + enable + enable + + + + + + + + + + + diff --git a/ChatService.Web/Configuration/BlobStorageSettings.cs b/ChatService.Web/Configuration/BlobStorageSettings.cs new file mode 100644 index 0000000..cb6eab8 --- /dev/null +++ b/ChatService.Web/Configuration/BlobStorageSettings.cs @@ -0,0 +1,6 @@ +namespace ChatService.Web.Configuration; + +public record BlobStorageSettings +{ + public string ConnectionString { get; init; } +} \ No newline at end of file diff --git a/ChatService.Web/Configuration/CosmosSettings.cs b/ChatService.Web/Configuration/CosmosSettings.cs new file mode 100644 index 0000000..6b03fed --- /dev/null +++ b/ChatService.Web/Configuration/CosmosSettings.cs @@ -0,0 +1,6 @@ +namespace ChatService.Web.Configuration; + +public record CosmosSettings +{ + public string ConnectionString { get; init; } +} \ No newline at end of file diff --git a/ChatService.Web/Controllers/ConversationsController.cs b/ChatService.Web/Controllers/ConversationsController.cs new file mode 100644 index 0000000..da713e1 --- /dev/null +++ b/ChatService.Web/Controllers/ConversationsController.cs @@ -0,0 +1,194 @@ +using System.Net; +using ChatService.Web.Dtos; +using ChatService.Web.Enums; +using ChatService.Web.Exceptions; +using ChatService.Web.Services; +using Microsoft.AspNetCore.Mvc; + +namespace ChatService.Web.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class ConversationsController : ControllerBase +{ + private readonly IUserConversationService _userConversationService; + private readonly IMessageService _messageService; + private readonly ILogger _logger; + + + public ConversationsController( + IUserConversationService userConversationService, + IMessageService messageService, + ILogger logger + ) + { + _userConversationService = userConversationService; + _messageService = messageService; + _logger = logger; + } + + [HttpGet] + public async Task> GetUserConversations(string username, + int limit = 10, OrderBy orderBy = OrderBy.DESC, string? continuationToken = null, long lastSeenConversationTime = 0) + { + try + { + GetUserConversationsParameters parameters = new() + { + Limit = limit, + Order = orderBy, + ContinuationToken = continuationToken, + LastSeenConversationTime = lastSeenConversationTime + }; + GetConversationsResult result = await _userConversationService.GetUserConversations(username, parameters); + + string nextUri = ""; + if (result.NextContinuationToken != null) + { + nextUri = "/api/conversations" + + $"?username={username}" + + $"&limit={limit}" + + $"&lastSeenConversationTime={lastSeenConversationTime}" + + $"&continuationToken={WebUtility.UrlEncode(result.NextContinuationToken)}"; + } + + GetUserConversationsResponse response = new() + { + Conversations = result.Conversations, + NextUri = nextUri + }; + + return Ok(response); + } + catch (Exception e) when (e is ArgumentException or InvalidContinuationTokenException) + { + return BadRequest(e.Message); + } + catch (UserNotFoundException e) + { + return NotFound(e.Message); + } + } + + [HttpPost] + public async Task> StartConversation(StartConversationRequest request) + { + using (_logger.BeginScope("{Username}", request.FirstMessage.SenderUsername)) + { + try + { + StartConversationResult result = await _userConversationService.StartConversation(request); + + _logger.LogInformation( + "Created user conversation with Id {ConversationId} for user {Username}", + result.ConversationId, request.FirstMessage.SenderUsername); + + StartConversationResponse response = new() + { + Id = result.ConversationId, + CreatedUnixTime = result.CreatedUnixTime + }; + + return CreatedAtAction(nameof(GetUserConversations), + new { username = request.FirstMessage.SenderUsername }, response); + } + catch (ArgumentException e) + { + return BadRequest(e.Message); + } + catch (UserNotFoundException e) + { + return NotFound(e.Message); + } + catch (UserNotParticipantException e) + { + _logger.LogError(e, "Error creating user conversation: {ErrorMessage}", e.Message); + return new ObjectResult(e.Message) { StatusCode = 403 }; + } + catch (Exception e) when (e is MessageExistsException or UserConversationExistsException) + { + _logger.LogError(e, "Error creating user conversation: {ErrorMessage}", e.Message); + return Conflict(e.Message); + } + } + } + + [HttpGet("{conversationId}/messages")] + public async Task> GetMessages(string conversationId, + int limit = 50, OrderBy orderBy = OrderBy.DESC, string? continuationToken = null, long lastSeenMessageTime = 0) + { + try + { + GetMessagesParameters parameters = new() + { + Limit = limit, + Order = orderBy, + ContinuationToken = continuationToken, + LastSeenMessageTime = lastSeenMessageTime + }; + GetMessagesResult result = await _messageService.GetMessages(conversationId, parameters); + + string nextUri = ""; + if (result.NextContinuationToken != null) + { + nextUri = $"/api/conversations/{conversationId}/messages" + + $"?limit={limit}" + + $"&continuationToken={WebUtility.UrlEncode(result.NextContinuationToken)}" + + $"&lastSeenConversationTime={lastSeenMessageTime}"; + } + + GetMessagesResponse response = new() + { + Messages = result.Messages, + NextUri = nextUri + }; + return Ok(response); + } + catch (Exception e) when (e is ArgumentException or InvalidContinuationTokenException) + { + return BadRequest(e.Message); + } + catch (ConversationDoesNotExistException e) + { + return NotFound(e.Message); + } + } + + [HttpPost("{conversationId}/messages")] + public async Task> PostMessage(string conversationId, SendMessageRequest request) + { + using (_logger.BeginScope(new Dictionary + { + {"ConversationId", conversationId}, + {"Username", request.SenderUsername} + })) + { + try + { + SendMessageResponse response = await _messageService.AddMessage(conversationId, isFirstMessage: false, request); + + _logger.LogInformation("Adding message {MessageId} to conversation {ConversationId} by sender {SenderUsername}", + request.Id, conversationId, request.SenderUsername); + + return CreatedAtAction(nameof(GetMessages), new { conversationId = conversationId }, response); + } + catch (ArgumentException e) + { + return BadRequest(e.Message); + } + catch (UserNotParticipantException e) + { + _logger.LogError(e, "Error adding message: {ErrorMessage}", e.Message); + return new ObjectResult(e.Message) { StatusCode = 403 }; + } + catch (Exception e) when (e is UserNotFoundException or ConversationDoesNotExistException) + { + return NotFound(e.Message); + } + catch (MessageExistsException e) + { + return Conflict(e.Message); + } + } + } +} \ No newline at end of file diff --git a/ChatService.Web/Controllers/ImagesController.cs b/ChatService.Web/Controllers/ImagesController.cs new file mode 100644 index 0000000..bc935fe --- /dev/null +++ b/ChatService.Web/Controllers/ImagesController.cs @@ -0,0 +1,58 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Exceptions; +using ChatService.Web.Services; +using Microsoft.AspNetCore.Mvc; + +namespace ChatService.Web.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class ImagesController : ControllerBase +{ + private readonly IImageService _imageService; + private readonly ILogger _logger; + + public ImagesController(IImageService imageService, ILogger logger) + { + _imageService = imageService; + _logger = logger; + } + + [HttpPost] + public async Task> UploadImage([FromForm] UploadImageRequest request) + { + MemoryStream content = new(); + await request.File.CopyToAsync(content); + Image image = new(request.File.ContentType, content); + + try + { + UploadImageResult result = await _imageService.UploadImage(image); + _logger.LogInformation("Uploaded image with id {id}.", result.ImageId); + return CreatedAtAction(nameof(DownloadImage), new { imageId = result.ImageId }, + new UploadImageResponse(result.ImageId)); + } + catch (InvalidImageTypeException e) + { + return BadRequest(e.Message); + } + } + + [HttpGet("{imageId}")] + public async Task DownloadImage(string imageId) + { + try + { + Image image = await _imageService.DownloadImage(imageId); + return new FileContentResult(image.Content.ToArray(), image.ContentType); + } + catch (ArgumentException e) + { + return BadRequest(e.Message); + } + catch (ImageNotFoundException e) + { + return NotFound(e.Message); + } + } +} \ No newline at end of file diff --git a/ChatService.Web/Controllers/ProfileController.cs b/ChatService.Web/Controllers/ProfileController.cs new file mode 100644 index 0000000..9557f6e --- /dev/null +++ b/ChatService.Web/Controllers/ProfileController.cs @@ -0,0 +1,61 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Exceptions; +using ChatService.Web.Services; +using Microsoft.AspNetCore.Mvc; + +namespace ChatService.Web.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class ProfileController : ControllerBase +{ + private readonly IProfileService _profileService; + private readonly ILogger _logger; + + public ProfileController(IProfileService profileService, ILogger logger) + { + _profileService = profileService; + _logger = logger; + + } + + [HttpGet("{username}")] + public async Task> GetProfile(string username) + { + using (_logger.BeginScope("{Username}", username)) + { + try + { + var profile = await _profileService.GetProfile(username); + return Ok(profile); + } + catch (UserNotFoundException e) + { + return NotFound(e.Message); + } + } + } + + [HttpPost] + public async Task> PostProfile(Profile profile) + { + using (_logger.BeginScope("{Username}", profile.Username)) + { + try + { + await _profileService.AddProfile(profile); + _logger.LogInformation("Created Profile for user {ProfileUsername}.", profile.Username); + return CreatedAtAction(nameof(GetProfile), new { username = profile.Username }, profile); + } + catch (Exception e) when (e is ArgumentException or ImageNotFoundException || e is InvalidUsernameException) + { + return BadRequest(e.Message); + } + catch (UsernameTakenException e) + { + _logger.LogError(e, "Error posting profile: {ErrorMessage}", e.Message); + return Conflict(e.Message); + } + } + } +} \ No newline at end of file diff --git a/ChatService.Web/Dtos/Conversation.cs b/ChatService.Web/Dtos/Conversation.cs new file mode 100644 index 0000000..e3385fd --- /dev/null +++ b/ChatService.Web/Dtos/Conversation.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatService.Web.Dtos; + +public record Conversation +{ + [Required] public string Id { get; set; } + [Required] public long LastModifiedUnixTime { get; set; } + [Required] public Profile Recipient { get; set; } +} \ No newline at end of file diff --git a/ChatService.Web/Dtos/GetConversationsResult.cs b/ChatService.Web/Dtos/GetConversationsResult.cs new file mode 100644 index 0000000..75b8be5 --- /dev/null +++ b/ChatService.Web/Dtos/GetConversationsResult.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatService.Web.Dtos; + +public record GetConversationsResult +{ + [Required] public List Conversations { get; set; } + [Required] public string? NextContinuationToken { get; set; } +} \ No newline at end of file diff --git a/ChatService.Web/Dtos/GetMessagesParameters.cs b/ChatService.Web/Dtos/GetMessagesParameters.cs new file mode 100644 index 0000000..da99f6e --- /dev/null +++ b/ChatService.Web/Dtos/GetMessagesParameters.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; +using ChatService.Web.Enums; + +namespace ChatService.Web.Dtos; + +public record GetMessagesParameters +{ + [Required] public int Limit { get; set; } + [Required] public OrderBy Order { get; set; } + public string? ContinuationToken { get; set; } + [Required] public long LastSeenMessageTime { get; set; } +}; \ No newline at end of file diff --git a/ChatService.Web/Dtos/GetMessagesResponse.cs b/ChatService.Web/Dtos/GetMessagesResponse.cs new file mode 100644 index 0000000..e87df01 --- /dev/null +++ b/ChatService.Web/Dtos/GetMessagesResponse.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatService.Web.Dtos; + +public record GetMessagesResponse +{ + [Required] public List Messages { get; set; } + [Required] public string NextUri { get; set; } +} \ No newline at end of file diff --git a/ChatService.Web/Dtos/GetMessagesResult.cs b/ChatService.Web/Dtos/GetMessagesResult.cs new file mode 100644 index 0000000..31bbbd4 --- /dev/null +++ b/ChatService.Web/Dtos/GetMessagesResult.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatService.Web.Dtos; + +public record GetMessagesResult +{ + [Required] public List Messages { get; set; } + [Required] public string? NextContinuationToken { get; set; } +} \ No newline at end of file diff --git a/ChatService.Web/Dtos/GetUserConversationsParameters.cs b/ChatService.Web/Dtos/GetUserConversationsParameters.cs new file mode 100644 index 0000000..3f1c347 --- /dev/null +++ b/ChatService.Web/Dtos/GetUserConversationsParameters.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; +using ChatService.Web.Enums; + +namespace ChatService.Web.Dtos; + +public record GetUserConversationsParameters +{ + [Required] public int Limit { get; set; } + [Required] public OrderBy Order { get; set; } + public string? ContinuationToken { get; set; } + [Required] public long LastSeenConversationTime { get; set; } +}; \ No newline at end of file diff --git a/ChatService.Web/Dtos/GetUserConversationsResponse.cs b/ChatService.Web/Dtos/GetUserConversationsResponse.cs new file mode 100644 index 0000000..5b9d11e --- /dev/null +++ b/ChatService.Web/Dtos/GetUserConversationsResponse.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatService.Web.Dtos; + +public record GetUserConversationsResponse +{ + [Required] public List Conversations { get; set; } + [Required] public string NextUri { get; set; } +} \ No newline at end of file diff --git a/ChatService.Web/Dtos/GetUserConversationsResult.cs b/ChatService.Web/Dtos/GetUserConversationsResult.cs new file mode 100644 index 0000000..bc0acd0 --- /dev/null +++ b/ChatService.Web/Dtos/GetUserConversationsResult.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatService.Web.Dtos; + +public record GetUserConversationsResult +{ + [Required] public List UserConversations { get; set; } + [Required] public string? NextContinuationToken { get; set; } +} \ No newline at end of file diff --git a/ChatService.Web/Dtos/Image.cs b/ChatService.Web/Dtos/Image.cs new file mode 100644 index 0000000..5ba38a9 --- /dev/null +++ b/ChatService.Web/Dtos/Image.cs @@ -0,0 +1,5 @@ +namespace ChatService.Web.Dtos; + +public record Image( + string ContentType, + MemoryStream Content); \ No newline at end of file diff --git a/ChatService.Web/Dtos/Message.cs b/ChatService.Web/Dtos/Message.cs new file mode 100644 index 0000000..c80ab77 --- /dev/null +++ b/ChatService.Web/Dtos/Message.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatService.Web.Dtos; + +public record Message +{ + [Required] public string Id { get; set; } + [Required] public long UnixTime { get; set; } + [Required] public string SenderUsername { get; set; } + [Required] public string Text { get; set; } +}; \ No newline at end of file diff --git a/ChatService.Web/Dtos/Profile.cs b/ChatService.Web/Dtos/Profile.cs new file mode 100644 index 0000000..ea1d0db --- /dev/null +++ b/ChatService.Web/Dtos/Profile.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatService.Web.Dtos; + +public record Profile +{ + [Required] public string Username { get; set; } + [Required] public string FirstName { get; set; } + [Required] public string LastName { get; set; } + public string? ProfilePictureId { get; set; } +} \ No newline at end of file diff --git a/ChatService.Web/Dtos/SendMessageRequest.cs b/ChatService.Web/Dtos/SendMessageRequest.cs new file mode 100644 index 0000000..e0e0b39 --- /dev/null +++ b/ChatService.Web/Dtos/SendMessageRequest.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatService.Web.Dtos; + +public record SendMessageRequest +{ + [Required] public string Id { get; set; } + [Required] public string SenderUsername { get; set; } + [Required] public string Text { get; set; } +} \ No newline at end of file diff --git a/ChatService.Web/Dtos/SendMessageResponse.cs b/ChatService.Web/Dtos/SendMessageResponse.cs new file mode 100644 index 0000000..44734d0 --- /dev/null +++ b/ChatService.Web/Dtos/SendMessageResponse.cs @@ -0,0 +1,8 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatService.Web.Dtos; + +public record SendMessageResponse +{ + [Required] public long CreatedUnixTime { get; set; } +} \ No newline at end of file diff --git a/ChatService.Web/Dtos/StartConversationRequest.cs b/ChatService.Web/Dtos/StartConversationRequest.cs new file mode 100644 index 0000000..27432ec --- /dev/null +++ b/ChatService.Web/Dtos/StartConversationRequest.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatService.Web.Dtos; + +public record StartConversationRequest +{ + [Required] public List Participants { get; set; } + [Required] public SendMessageRequest FirstMessage { get; set; } +} \ No newline at end of file diff --git a/ChatService.Web/Dtos/StartConversationResponse.cs b/ChatService.Web/Dtos/StartConversationResponse.cs new file mode 100644 index 0000000..b80bece --- /dev/null +++ b/ChatService.Web/Dtos/StartConversationResponse.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatService.Web.Dtos; + +public record StartConversationResponse +{ + [Required] public string Id { get; set; } + [Required] public long CreatedUnixTime { get; set; } +} \ No newline at end of file diff --git a/ChatService.Web/Dtos/StartConversationResult.cs b/ChatService.Web/Dtos/StartConversationResult.cs new file mode 100644 index 0000000..fbb2a35 --- /dev/null +++ b/ChatService.Web/Dtos/StartConversationResult.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatService.Web.Dtos; + +public record StartConversationResult +{ + [Required] public string ConversationId { get; set; } + [Required] public long CreatedUnixTime { get; set; } +} \ No newline at end of file diff --git a/ChatService.Web/Dtos/UploadImageRequest.cs b/ChatService.Web/Dtos/UploadImageRequest.cs new file mode 100644 index 0000000..66b6670 --- /dev/null +++ b/ChatService.Web/Dtos/UploadImageRequest.cs @@ -0,0 +1,6 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatService.Web.Dtos; + +public record UploadImageRequest( + [Required] IFormFile File); \ No newline at end of file diff --git a/ChatService.Web/Dtos/UploadImageResponse.cs b/ChatService.Web/Dtos/UploadImageResponse.cs new file mode 100644 index 0000000..a95c54e --- /dev/null +++ b/ChatService.Web/Dtos/UploadImageResponse.cs @@ -0,0 +1,4 @@ +namespace ChatService.Web.Dtos; + +public record UploadImageResponse( + string ImageId); \ No newline at end of file diff --git a/ChatService.Web/Dtos/UploadImageResult.cs b/ChatService.Web/Dtos/UploadImageResult.cs new file mode 100644 index 0000000..ceb42f2 --- /dev/null +++ b/ChatService.Web/Dtos/UploadImageResult.cs @@ -0,0 +1,4 @@ +namespace ChatService.Web.Dtos; + +public record UploadImageResult( + string ImageId); \ No newline at end of file diff --git a/ChatService.Web/Dtos/UserConversation.cs b/ChatService.Web/Dtos/UserConversation.cs new file mode 100644 index 0000000..72b379a --- /dev/null +++ b/ChatService.Web/Dtos/UserConversation.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Dtos; + +public record UserConversation +{ + public string Username { get; set; } + public string ConversationId { get; set; } + public long LastModifiedTime { get; set; } +}; \ No newline at end of file diff --git a/ChatService.Web/Enums/OrderBy.cs b/ChatService.Web/Enums/OrderBy.cs new file mode 100644 index 0000000..5ecb8d4 --- /dev/null +++ b/ChatService.Web/Enums/OrderBy.cs @@ -0,0 +1,7 @@ +namespace ChatService.Web.Enums; + +public enum OrderBy +{ + ASC, + DESC +} \ No newline at end of file diff --git a/ChatService.Web/Exceptions/BlobServiceUnavailableException.cs b/ChatService.Web/Exceptions/BlobServiceUnavailableException.cs new file mode 100644 index 0000000..f1e4637 --- /dev/null +++ b/ChatService.Web/Exceptions/BlobServiceUnavailableException.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Exceptions; + +public class BlobServiceUnavailableException : Exception +{ + public BlobServiceUnavailableException(string? message) : base(message) + { + } +} \ No newline at end of file diff --git a/ChatService.Web/Exceptions/ConversationDoesNotExistException.cs b/ChatService.Web/Exceptions/ConversationDoesNotExistException.cs new file mode 100644 index 0000000..03bd247 --- /dev/null +++ b/ChatService.Web/Exceptions/ConversationDoesNotExistException.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Exceptions; + +public class ConversationDoesNotExistException : Exception +{ + public ConversationDoesNotExistException(string? message) : base(message) + { + } +} \ No newline at end of file diff --git a/ChatService.Web/Exceptions/ConversationExistsException.cs b/ChatService.Web/Exceptions/ConversationExistsException.cs new file mode 100644 index 0000000..84e4e6e --- /dev/null +++ b/ChatService.Web/Exceptions/ConversationExistsException.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Exceptions; + +public class ConversationExistsException : Exception +{ + public ConversationExistsException(string? message) : base(message) + { + } +} \ No newline at end of file diff --git a/ChatService.Web/Exceptions/CosmosServiceUnavailableException.cs b/ChatService.Web/Exceptions/CosmosServiceUnavailableException.cs new file mode 100644 index 0000000..34f7a5a --- /dev/null +++ b/ChatService.Web/Exceptions/CosmosServiceUnavailableException.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Exceptions; + +public class CosmosServiceUnavailableException : Exception +{ + public CosmosServiceUnavailableException(string? message) : base(message) + { + } +} \ No newline at end of file diff --git a/ChatService.Web/Exceptions/ImageNotFoundException.cs b/ChatService.Web/Exceptions/ImageNotFoundException.cs new file mode 100644 index 0000000..7103896 --- /dev/null +++ b/ChatService.Web/Exceptions/ImageNotFoundException.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Exceptions; + +public class ImageNotFoundException : Exception +{ + public ImageNotFoundException(string? message) : base(message) + { + } +} \ No newline at end of file diff --git a/ChatService.Web/Exceptions/InvalidContinuationTokenException.cs b/ChatService.Web/Exceptions/InvalidContinuationTokenException.cs new file mode 100644 index 0000000..89a67dd --- /dev/null +++ b/ChatService.Web/Exceptions/InvalidContinuationTokenException.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Exceptions; + +public class InvalidContinuationTokenException : Exception +{ + public InvalidContinuationTokenException(string? message) : base(message) + { + } +} \ No newline at end of file diff --git a/ChatService.Web/Exceptions/InvalidImageTypeException.cs b/ChatService.Web/Exceptions/InvalidImageTypeException.cs new file mode 100644 index 0000000..4801e82 --- /dev/null +++ b/ChatService.Web/Exceptions/InvalidImageTypeException.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Exceptions; + +public class InvalidImageTypeException : Exception +{ + public InvalidImageTypeException(string? message) : base(message) + { + } +} \ No newline at end of file diff --git a/ChatService.Web/Exceptions/InvalidUsernameException.cs b/ChatService.Web/Exceptions/InvalidUsernameException.cs new file mode 100644 index 0000000..c6a8c3a --- /dev/null +++ b/ChatService.Web/Exceptions/InvalidUsernameException.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Exceptions; + +public class InvalidUsernameException : Exception +{ + public InvalidUsernameException(string? message) : base(message) + { + } +} \ No newline at end of file diff --git a/ChatService.Web/Exceptions/MessageExistsException.cs b/ChatService.Web/Exceptions/MessageExistsException.cs new file mode 100644 index 0000000..466d8e5 --- /dev/null +++ b/ChatService.Web/Exceptions/MessageExistsException.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Exceptions; + +public class MessageExistsException : Exception +{ + public MessageExistsException(string? message) : base(message) + { + } +} \ No newline at end of file diff --git a/ChatService.Web/Exceptions/MessageNotFoundException.cs b/ChatService.Web/Exceptions/MessageNotFoundException.cs new file mode 100644 index 0000000..d166121 --- /dev/null +++ b/ChatService.Web/Exceptions/MessageNotFoundException.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Exceptions; + +public class MessageNotFoundException : Exception +{ + public MessageNotFoundException(string? message) : base(message) + { + } +} \ No newline at end of file diff --git a/ChatService.Web/Exceptions/ProfileExistsException.cs b/ChatService.Web/Exceptions/ProfileExistsException.cs new file mode 100644 index 0000000..0763105 --- /dev/null +++ b/ChatService.Web/Exceptions/ProfileExistsException.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Exceptions; + +public class UsernameTakenException : Exception +{ + public UsernameTakenException(string? message) : base(message) + { + } +} \ No newline at end of file diff --git a/ChatService.Web/Exceptions/UserConversationExistsException.cs b/ChatService.Web/Exceptions/UserConversationExistsException.cs new file mode 100644 index 0000000..eeef0e7 --- /dev/null +++ b/ChatService.Web/Exceptions/UserConversationExistsException.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Exceptions; + +public class UserConversationExistsException : Exception +{ + public UserConversationExistsException(string? message) : base(message) + { + } +} \ No newline at end of file diff --git a/ChatService.Web/Exceptions/UserConversationNotFoundException.cs b/ChatService.Web/Exceptions/UserConversationNotFoundException.cs new file mode 100644 index 0000000..4f3b566 --- /dev/null +++ b/ChatService.Web/Exceptions/UserConversationNotFoundException.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Exceptions; + +public class UserConversationNotFoundException : Exception +{ + public UserConversationNotFoundException(string? message) : base(message) + { + } +} \ No newline at end of file diff --git a/ChatService.Web/Exceptions/UserNotFoundException.cs b/ChatService.Web/Exceptions/UserNotFoundException.cs new file mode 100644 index 0000000..77e8e75 --- /dev/null +++ b/ChatService.Web/Exceptions/UserNotFoundException.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Exceptions; + +public class UserNotFoundException : Exception +{ + public UserNotFoundException(string? message) : base(message) + { + } +} \ No newline at end of file diff --git a/ChatService.Web/Exceptions/UserNotParticipantException.cs b/ChatService.Web/Exceptions/UserNotParticipantException.cs new file mode 100644 index 0000000..e76412f --- /dev/null +++ b/ChatService.Web/Exceptions/UserNotParticipantException.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Exceptions; + +public class UserNotParticipantException : Exception +{ + public UserNotParticipantException(string? message) : base(message) + { + } +} \ No newline at end of file diff --git a/ChatService.Web/Middleware/ExceptionMiddleware.cs b/ChatService.Web/Middleware/ExceptionMiddleware.cs new file mode 100644 index 0000000..4fa22de --- /dev/null +++ b/ChatService.Web/Middleware/ExceptionMiddleware.cs @@ -0,0 +1,61 @@ +using ChatService.Web.Exceptions; +using Newtonsoft.Json; + +namespace ChatService.Web.Middleware; + +public class ExceptionMiddleware +{ + // ExceptionMiddleware code adopted from Professor Nehme Bilal: + // https://medium.com/technology-earnin/thorough-testing-of-asp-net-core-or-any-web-api-a87bd0585f9b + 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 CosmosServiceUnavailableException or BlobServiceUnavailableException) + { + 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/ChatService.Web/Program.cs b/ChatService.Web/Program.cs new file mode 100644 index 0000000..b4d01ae --- /dev/null +++ b/ChatService.Web/Program.cs @@ -0,0 +1,59 @@ +using Azure.Storage.Blobs; +using ChatService.Web.Configuration; +using ChatService.Web.Middleware; +using ChatService.Web.Services; +using ChatService.Web.Storage; +using Microsoft.Azure.Cosmos; +using Microsoft.Extensions.Options; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.Configure(builder.Configuration.GetSection("Cosmos")); +builder.Services.Configure(builder.Configuration.GetSection("BlobStorage")); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +builder.Services.AddSingleton(sp => +{ + var cosmosOptions = sp.GetRequiredService>(); + return new CosmosClient(cosmosOptions.Value.ConnectionString); +}); +builder.Services.AddSingleton(sp => + { + var blobOptions = sp.GetRequiredService>(); + return new BlobServiceClient(blobOptions.Value.ConnectionString); + } +); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddApplicationInsightsTelemetry(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseMiddleware(); + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); + +public partial class Program { } \ No newline at end of file diff --git a/ChatService.Web/Properties/launchSettings.json b/ChatService.Web/Properties/launchSettings.json new file mode 100644 index 0000000..1c4e758 --- /dev/null +++ b/ChatService.Web/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "ChatService.Web": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7185;http://localhost:5157", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/ChatService.Web/Services/IImageService.cs b/ChatService.Web/Services/IImageService.cs new file mode 100644 index 0000000..fdf3bb2 --- /dev/null +++ b/ChatService.Web/Services/IImageService.cs @@ -0,0 +1,12 @@ +using ChatService.Web.Dtos; +using Microsoft.AspNetCore.Mvc; + +namespace ChatService.Web.Services; + +public interface IImageService +{ + Task UploadImage(Image image); + Task DownloadImage(string imageId); + Task DeleteImage(string imageId); + Task ImageExists(string imageId); +} \ No newline at end of file diff --git a/ChatService.Web/Services/IMessageService.cs b/ChatService.Web/Services/IMessageService.cs new file mode 100644 index 0000000..553b05b --- /dev/null +++ b/ChatService.Web/Services/IMessageService.cs @@ -0,0 +1,9 @@ +using ChatService.Web.Dtos; + +namespace ChatService.Web.Services; + +public interface IMessageService +{ + Task AddMessage(string conversationId, bool isFirstMessage, SendMessageRequest request); + Task GetMessages(string conversationId, GetMessagesParameters parameters); +} \ No newline at end of file diff --git a/ChatService.Web/Services/IProfileService.cs b/ChatService.Web/Services/IProfileService.cs new file mode 100644 index 0000000..28c493d --- /dev/null +++ b/ChatService.Web/Services/IProfileService.cs @@ -0,0 +1,11 @@ +using ChatService.Web.Dtos; + +namespace ChatService.Web.Services; + +public interface IProfileService +{ + Task GetProfile(string username); + Task AddProfile(Profile profile); + Task ProfileExists(string username); + Task DeleteProfile(string username); +} \ No newline at end of file diff --git a/ChatService.Web/Services/IUserConversationService.cs b/ChatService.Web/Services/IUserConversationService.cs new file mode 100644 index 0000000..23fbe23 --- /dev/null +++ b/ChatService.Web/Services/IUserConversationService.cs @@ -0,0 +1,10 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Enums; + +namespace ChatService.Web.Services; + +public interface IUserConversationService +{ + Task StartConversation(StartConversationRequest request); + Task GetUserConversations(string username, GetUserConversationsParameters parameters); +} \ No newline at end of file diff --git a/ChatService.Web/Services/ImageService.cs b/ChatService.Web/Services/ImageService.cs new file mode 100644 index 0000000..e6a22c2 --- /dev/null +++ b/ChatService.Web/Services/ImageService.cs @@ -0,0 +1,68 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Exceptions; +using ChatService.Web.Storage; + +namespace ChatService.Web.Services; + +public class ImageService : IImageService +{ + private readonly IImageStore _imageStore; + + public ImageService(IImageStore imageStore) + { + _imageStore = imageStore; + } + + public async Task UploadImage(Image image) + { + // ValidateImage(image); + + string imageId = await _imageStore.UploadImage(image); + + return new UploadImageResult(imageId); + } + + public async Task DownloadImage(string imageId) + { + ValidateImageId(imageId); + + Image? image = await _imageStore.DownloadImage(imageId); + + if (image == null) + { + throw new ImageNotFoundException($"An image with id {imageId} was not found."); + } + + return image; + } + + public async Task DeleteImage(string imageId) + { + await _imageStore.DeleteImage(imageId); + } + + public async Task ImageExists(string imageId) + { + return await _imageStore.ImageExists(imageId); + } + + private void ValidateImage(Image image) + { + string contentType = image.ContentType.ToLower(); + + if (contentType != "image/jpg" && + contentType != "image/jpeg" && + contentType != "image/png") + { + throw new InvalidImageTypeException($"Invalid image type {contentType}."); + } + } + + private void ValidateImageId(string imageId) + { + if (string.IsNullOrWhiteSpace(imageId)) + { + throw new ArgumentException("Invalid imageId"); + } + } +} \ No newline at end of file diff --git a/ChatService.Web/Services/MessageService.cs b/ChatService.Web/Services/MessageService.cs new file mode 100644 index 0000000..58a375d --- /dev/null +++ b/ChatService.Web/Services/MessageService.cs @@ -0,0 +1,160 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Enums; +using ChatService.Web.Exceptions; +using ChatService.Web.Storage; +using ChatService.Web.Utilities; + +namespace ChatService.Web.Services; + +public class MessageService : IMessageService +{ + private readonly IMessageStore _messageStore; + private readonly IUserConversationStore _userConversationStore; + private readonly IProfileService _profileService; + + public MessageService(IMessageStore messageStore, IUserConversationStore userConversationStore, + IProfileService profileService) + { + _messageStore = messageStore; + _userConversationStore = userConversationStore; + _profileService = profileService; + } + + public async Task AddMessage(string conversationId, bool isFirstMessage, + SendMessageRequest request) + { + ValidateSendMessageRequest(request); + ValidateConversationId(conversationId); + if (!isFirstMessage) + { + await CheckIfConversationExists(conversationId); + } + await ThrowIfUserNotFound(request.SenderUsername); + + AuthorizeSender(conversationId, request.SenderUsername); + + long unixTimeNow = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + Message message = new() + { + Id = request.Id, + UnixTime = unixTimeNow, + SenderUsername = request.SenderUsername, + Text = request.Text + }; + + try + { + await _messageStore.AddMessage(conversationId, message); + } + catch (MessageExistsException e) + { + await _messageStore.UpdateMessageTime(conversationId, message); + throw; + } + + await UpdateUserConversationsLastModifiedTime(conversationId, unixTimeNow); + + return new SendMessageResponse + { + CreatedUnixTime = unixTimeNow + }; + } + + public async Task GetMessages(string conversationId, GetMessagesParameters parameters) + { + ValidateConversationId(conversationId); + ValidateLimit(parameters.Limit); + ValidateLastSeenConversationTime(parameters.LastSeenMessageTime); + await CheckIfConversationExists(conversationId); + + return await _messageStore.GetMessages(conversationId, parameters); + } + + private async Task UpdateUserConversationsLastModifiedTime(string conversationId, long unixTime) + { + string[] usernames = ConversationIdUtilities.SplitConversationId(conversationId); + UserConversation userConversation1 = CreateUserConversationObject(usernames[0], conversationId, + lastModifiedTime: unixTime); + UserConversation userConversation2 = CreateUserConversationObject(usernames[1], conversationId, + lastModifiedTime: unixTime); + + await Task.WhenAll( + _userConversationStore.UpsertUserConversation(userConversation1), + _userConversationStore.UpsertUserConversation(userConversation2)); + } + + private void ValidateSendMessageRequest(SendMessageRequest request) + { + if (request == null || + string.IsNullOrWhiteSpace(request.Id) || + string.IsNullOrWhiteSpace(request.SenderUsername) || + string.IsNullOrWhiteSpace(request.Text) + ) + { + throw new ArgumentException($"Invalid SendMessageRequest {request}."); + } + } + + private void ValidateConversationId(string conversationId) + { + ConversationIdUtilities.ValidateConversationId(conversationId); + } + + private void ValidateLimit(int limit) + { + if (limit <= 0) + { + throw new ArgumentException($"Invalid limit {limit}. Limit must be greater or equal to 1."); + } + } + + private void ValidateLastSeenConversationTime(long lastSeenConversationTime) + { + if (lastSeenConversationTime < 0) + { + throw new ArgumentException($"Invalid lastSeenConversationTime {lastSeenConversationTime}. " + + $"LastSeenConversationTime must be greater or equal to 0."); + } + } + + private async Task ThrowIfUserNotFound(string username) + { + bool profileExists = await _profileService.ProfileExists(username); + if (!profileExists) + { + throw new UserNotFoundException($"A user with the username {username} was not found."); + } + } + + private void AuthorizeSender(string conversationId, string senderUsername) + { + string[] usernames = ConversationIdUtilities.SplitConversationId(conversationId); + bool userNotParticipant = !usernames[0].Equals(senderUsername) && !usernames[1].Equals(senderUsername); + if (userNotParticipant) + { + throw new UserNotParticipantException( + $"User {senderUsername} is not a participant of conversation {conversationId}."); + } + } + + private async Task CheckIfConversationExists(string conversationId) + { + bool conversationExists = await _messageStore.ConversationExists(conversationId); + if (!conversationExists) + { + throw new ConversationDoesNotExistException( + $"A conversation partition with the conversationId {conversationId} does not exist."); + } + } + + private UserConversation CreateUserConversationObject(string username, string conversationId, long lastModifiedTime) + { + return new UserConversation + { + Username = username, + ConversationId = conversationId, + LastModifiedTime = lastModifiedTime + }; + } +} \ No newline at end of file diff --git a/ChatService.Web/Services/ProfileService.cs b/ChatService.Web/Services/ProfileService.cs new file mode 100644 index 0000000..dd132fa --- /dev/null +++ b/ChatService.Web/Services/ProfileService.cs @@ -0,0 +1,94 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Exceptions; +using ChatService.Web.Storage; +using ChatService.Web.Utilities; + +namespace ChatService.Web.Services; + +public class ProfileService : IProfileService +{ + private readonly IProfileStore _profileStore; + private readonly IImageService _imageService; + + public ProfileService(IProfileStore profileStore, IImageService imageService) + { + _profileStore = profileStore; + _imageService = imageService; + } + + public async Task GetProfile(string username) + { + ValidateUsername(username); + + var profile = await _profileStore.GetProfile(username); + + if (profile == null) + { + throw new UserNotFoundException( + $"A profile with the username {username} was not found."); + } + + return profile; + } + + public async Task AddProfile(Profile profile) + { + ValidateProfile(profile); + // await ThrowIfImageNotFound(profile.ProfilePictureId); + await _profileStore.AddProfile(profile); + } + + public async Task ProfileExists(string username) + { + if (string.IsNullOrWhiteSpace(username)) + { + throw new ArgumentException($"Invalid username {username}."); + + } + return await _profileStore.ProfileExists(username); + } + + public async Task DeleteProfile(string username) + { + Profile? profile = await GetProfile(username); + if (profile == null) + { + throw new UserNotFoundException($"A user with the username {username} was not found."); + } + await Task.WhenAll( + _imageService.DeleteImage(profile.ProfilePictureId), + _profileStore.DeleteProfile(username) + ); + } + + private void ValidateUsername(string username) + { + if (string.IsNullOrWhiteSpace(username)) + { + throw new ArgumentException($"Invalid username {username}"); + } + } + + private void ValidateProfile(Profile profile) + { + if (profile == null || + string.IsNullOrWhiteSpace(profile.FirstName) || + string.IsNullOrWhiteSpace(profile.LastName) + // string.IsNullOrWhiteSpace(profile.ProfilePictureId) + ) + { + throw new ArgumentException($"Invalid profile {profile}", nameof(profile)); + } + ValidateUsername(profile.Username); + ConversationIdUtilities.ValidateUsernameSeparator(profile.Username); + } + + // private async Task ThrowIfImageNotFound(string profilePictureId) + // { + // bool imageExists = await _imageService.ImageExists(profilePictureId); + // if (!imageExists) + // { + // throw new ImageNotFoundException($"Profile picture with ID {profilePictureId} was not found."); + // } + // } +} \ No newline at end of file diff --git a/ChatService.Web/Services/UserConversationService.cs b/ChatService.Web/Services/UserConversationService.cs new file mode 100644 index 0000000..44a552c --- /dev/null +++ b/ChatService.Web/Services/UserConversationService.cs @@ -0,0 +1,166 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Enums; +using ChatService.Web.Exceptions; +using ChatService.Web.Storage; +using ChatService.Web.Utilities; + +namespace ChatService.Web.Services; + +public class UserConversationService : IUserConversationService +{ + private readonly IMessageService _messageService; + private readonly IUserConversationStore _userConversationStore; + private readonly IProfileService _profileService; + + public UserConversationService(IMessageService messageService, IUserConversationStore userConversationStore, + IProfileService profileService) + { + _messageService = messageService; + _userConversationStore = userConversationStore; + _profileService = profileService; + } + + public async Task StartConversation(StartConversationRequest request) + { + ValidateStartConversationRequest(request); + await EnsureThatParticipantsExist(request.Participants); + + string username1 = request.Participants.ElementAt(0); + string username2 = request.Participants.ElementAt(1); + string conversationId = ConversationIdUtilities.GenerateConversationId(username1, username2); + + SendMessageRequest sendMessageRequest = new() + { + Id = request.FirstMessage.Id, + SenderUsername = request.FirstMessage.SenderUsername, + Text = request.FirstMessage.Text + }; + await _messageService.AddMessage(conversationId, isFirstMessage: true, sendMessageRequest); + + long unixTimeNow = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + await Task.WhenAll( + CreateUserConversation(username1, conversationId, unixTimeNow), + CreateUserConversation(username2, conversationId, unixTimeNow) + ); + + return new StartConversationResult + { + ConversationId = conversationId, + CreatedUnixTime = unixTimeNow + }; + } + + public async Task GetUserConversations( + string username, GetUserConversationsParameters parameters) + { + if (string.IsNullOrWhiteSpace(username)) + { + throw new ArgumentException($"Invalid username {username}."); + } + + if (parameters.Limit <= 0) + { + throw new ArgumentException($"Invalid limit {parameters.Limit}. Limit must be greater or equal to 1."); + } + + if (parameters.LastSeenConversationTime < 0) + { + throw new ArgumentException( + $"Invalid lastSeenConversationTime {parameters.LastSeenConversationTime}. lastSeenConversationTime must be greater or equal to 0."); + } + + await ThrowIfParticipantNotFound(username); + + var result = await _userConversationStore.GetUserConversations(username, parameters); + + List conversations = await UserConversationsToConversations(result.UserConversations); + + return new GetConversationsResult + { + Conversations = conversations, + NextContinuationToken = result.NextContinuationToken + }; + } + + private async Task> UserConversationsToConversations(List userConversations) + { + Conversation[] conversations = new Conversation[userConversations.Count]; + + await Task.WhenAll(userConversations.Select(async (userConversation, index) => + { + string[] usernames = ConversationIdUtilities.SplitConversationId(userConversation.ConversationId); + string recipientUsername = GetRecipientUsername(senderUsername: userConversation.Username, usernames); + + Profile recipientProfile = await _profileService.GetProfile(recipientUsername); + + Conversation conversation = new() + { + Id = userConversation.ConversationId, + LastModifiedUnixTime = userConversation.LastModifiedTime, + Recipient = recipientProfile + }; + conversations[index] = conversation; + })); + + return conversations.ToList(); + } + + private void ValidateStartConversationRequest(StartConversationRequest request) + { + if (request == null) + { + throw new ArgumentException($"StartConversationRequest is null."); + } + + if (request.Participants.Count < 2 || + string.IsNullOrWhiteSpace(request.Participants.ElementAt(0)) || + string.IsNullOrWhiteSpace(request.Participants.ElementAt(1)) || + request.Participants.ElementAt(0).Equals(request.Participants.ElementAt(1))) + { + throw new ArgumentException( + $"Invalid participants list ${request.Participants}. There must be 2 unique participant usernames"); + } + + if (string.IsNullOrWhiteSpace(request.FirstMessage.Id) || + string.IsNullOrWhiteSpace(request.FirstMessage.SenderUsername) || + string.IsNullOrWhiteSpace(request.FirstMessage.Text)) + { + throw new ArgumentException($"Invalid FirstMessage {request.FirstMessage}."); + } + } + + private async Task CreateUserConversation(string username, string conversationId, long lastModifiedTime) + { + UserConversation userConversation = new() + { + Username = username, + ConversationId = conversationId, + LastModifiedTime = lastModifiedTime + }; + await _userConversationStore.UpsertUserConversation(userConversation); + } + + private async Task EnsureThatParticipantsExist(List participants) + { + await Task.WhenAll(participants.Select(ThrowIfParticipantNotFound)); + } + + private async Task ThrowIfParticipantNotFound(string username) + { + bool profileExists = await _profileService.ProfileExists(username); + if (!profileExists) + { + throw new UserNotFoundException($"A user with the username {username} was not found."); + } + } + + private string GetRecipientUsername(string senderUsername, string[] usernames) + { + if (senderUsername.Equals(usernames[0])) + { + return usernames[1]; + } + return usernames[0]; + } +} \ No newline at end of file diff --git a/ChatService.Web/Storage/BlobImageStore.cs b/ChatService.Web/Storage/BlobImageStore.cs new file mode 100644 index 0000000..915985c --- /dev/null +++ b/ChatService.Web/Storage/BlobImageStore.cs @@ -0,0 +1,88 @@ +using Azure; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using ChatService.Web.Dtos; +using ChatService.Web.Utilities; + +namespace ChatService.Web.Storage; + +public class BlobImageStore : IImageStore +{ + private readonly BlobServiceClient _blobServiceClient; + + public BlobImageStore(BlobServiceClient blobServiceClient) + { + _blobServiceClient = blobServiceClient; + } + + private BlobContainerClient BlobContainerClient => _blobServiceClient.GetBlobContainerClient("images"); + + public async Task UploadImage(Image image) + { + try + { + // ValidateImage(image); + + string imageId = Guid.NewGuid().ToString(); + BlobClient blobClient = BlobContainerClient.GetBlobClient(imageId); + BlobHttpHeaders headers = new() + { + ContentType = image.ContentType + }; + image.Content.Position = 0; + await blobClient.UploadAsync(image.Content, headers); + return imageId; + } + catch (RequestFailedException ex) + { + ServiceAvailabilityCheckerUtilities.ThrowIfBlobUnavailable(ex); + throw; + } + } + + public async Task DownloadImage(string id) + { + BlobClient blobClient = BlobContainerClient.GetBlobClient(id); + + try + { + MemoryStream content = new(); + await blobClient.DownloadToAsync(content); + BlobProperties properties = await blobClient.GetPropertiesAsync(); + string contentType = properties.ContentType; + return new Image(contentType, content); + } + catch (RequestFailedException ex) + { + if (ex.Status == 404) + { + return null; + } + ServiceAvailabilityCheckerUtilities.ThrowIfBlobUnavailable(ex); + throw; + } + } + + public async Task DeleteImage(string id) + { + BlobClient blobClient = BlobContainerClient.GetBlobClient(id); + return await blobClient.DeleteIfExistsAsync(); + } + + public async Task ImageExists(string id) + { + BlobClient blobClient = BlobContainerClient.GetBlobClient(id); + return await blobClient.ExistsAsync(); + } + + private void ValidateImage(Image image) + { + string contentType = image.ContentType.ToLower(); + if (contentType != "image/jpg" && + contentType != "image/jpeg" && + contentType != "image/png") + { + throw new ArgumentException("File type is not an image."); + } + } +} \ No newline at end of file diff --git a/ChatService.Web/Storage/CosmosMessageStore.cs b/ChatService.Web/Storage/CosmosMessageStore.cs new file mode 100644 index 0000000..5b45863 --- /dev/null +++ b/ChatService.Web/Storage/CosmosMessageStore.cs @@ -0,0 +1,239 @@ +using System.Net; +using ChatService.Web.Dtos; +using ChatService.Web.Enums; +using ChatService.Web.Exceptions; +using ChatService.Web.Storage.Entities; +using ChatService.Web.Utilities; +using Microsoft.Azure.Cosmos; +using Microsoft.Azure.Cosmos.Linq; + +namespace ChatService.Web.Storage; + +public class CosmosMessageStore : IMessageStore +{ + private readonly CosmosClient _cosmosClient; + + public CosmosMessageStore(CosmosClient cosmosClient) + { + _cosmosClient = cosmosClient; + } + + private Container Container => _cosmosClient.GetDatabase("chatService").GetContainer("sharedContainer"); + + public async Task AddMessage(string conversationId, Message message) + { + ValidateMessage(message); + + try + { + await Container.CreateItemAsync(ToEntity(conversationId, message), new PartitionKey(conversationId)); + } + catch (CosmosException e) + { + if (e.StatusCode == HttpStatusCode.Conflict) + { + throw new MessageExistsException($"A message with ID {message.Id} already exists."); + } + ServiceAvailabilityCheckerUtilities.ThrowIfCosmosUnavailable(e); + throw; + } + } + + public async Task UpdateMessageTime(string conversationId, Message message) + { + ValidateMessage(message); + + try + { + await Container.UpsertItemAsync(ToEntity(conversationId, message), new PartitionKey(conversationId)); + } + catch (CosmosException e) + { + ServiceAvailabilityCheckerUtilities.ThrowIfCosmosUnavailable(e); + throw; + } + } + + public async Task GetMessage(string conversationId, string messageId) + { + ValidateConversationId(conversationId); + ValidateMessageId(messageId); + + try + { + var entity = await Container.ReadItemAsync( + id: messageId, + partitionKey: new PartitionKey(conversationId), + new ItemRequestOptions + { + ConsistencyLevel = ConsistencyLevel.Session + } + ); + return ToMessage(entity); + } + catch (CosmosException e) + { + if (e.StatusCode == HttpStatusCode.NotFound) + { + return null; + } + ServiceAvailabilityCheckerUtilities.ThrowIfCosmosUnavailable(e); + throw; + } + } + + public async Task GetMessages(string conversationId, GetMessagesParameters parameters) + { + ValidateConversationId(conversationId); + ValidateLimit(parameters.Limit); + ValidateLastSeenMessageTime(parameters.LastSeenMessageTime); + + List messages = new(); + string? nextContinuationToken = null; + + QueryRequestOptions options = new(); + options.MaxItemCount = parameters.Limit; + + try + { + IQueryable query = Container + .GetItemLinqQueryable( + allowSynchronousQueryExecution: false, parameters.ContinuationToken, options) + .Where(e => e.partitionKey == conversationId && e.UnixTime > parameters.LastSeenMessageTime); + + if (parameters.Order == OrderBy.ASC) + { + query = query.OrderBy(e => e.UnixTime); + } + else + { + query = query.OrderByDescending(e => e.UnixTime); + } + + using (FeedIterator iterator = query.ToFeedIterator()) + { + FeedResponse response = await iterator.ReadNextAsync(); + var receivedMessages = response.Select(ToMessage); + + messages.AddRange(receivedMessages); + + nextContinuationToken = response.ContinuationToken; + }; + + return new GetMessagesResult + { + Messages = messages, + NextContinuationToken = nextContinuationToken + }; + } + catch (CosmosException e) + { + if (e.StatusCode == HttpStatusCode.BadRequest) + { + throw new InvalidContinuationTokenException($"Continuation token {parameters.ContinuationToken} is invalid."); + } + ServiceAvailabilityCheckerUtilities.ThrowIfCosmosUnavailable(e); + throw; + } + } + + public async Task ConversationExists(string conversationId) + { + GetMessagesParameters parameters = new() + { + Limit = 1, + Order = OrderBy.ASC, + ContinuationToken = null, + LastSeenMessageTime = 0 + }; + GetMessagesResult result = await GetMessages(conversationId, parameters); + return (result.Messages.Count > 0); + } + + public async Task DeleteMessage(string conversationId, string messageId) + { + try + { + await Container.DeleteItemAsync( + id: messageId, + partitionKey: new PartitionKey(conversationId)); + } + catch (CosmosException e) + { + if (e.StatusCode == HttpStatusCode.NotFound) + { + return; + } + ServiceAvailabilityCheckerUtilities.ThrowIfCosmosUnavailable(e); + throw; + } + } + + private static MessageEntity ToEntity(string conversationId, Message message) + { + return new MessageEntity( + partitionKey: conversationId, + id: message.Id, + message.UnixTime, + message.SenderUsername, + message.Text + ); + } + + private static Message ToMessage(MessageEntity entity) + { + return new Message + { + Id = entity.id, + UnixTime = entity.UnixTime, + SenderUsername = entity.SenderUsername, + Text = entity.Text + }; + } + + private void ValidateMessage(Message message) + { + if (message == null || + string.IsNullOrWhiteSpace(message.Id) || + string.IsNullOrWhiteSpace(message.SenderUsername) || + string.IsNullOrWhiteSpace(message.Text) || + message.UnixTime < 0 + ) + { + throw new ArgumentException($"Invalid message {message}", nameof(message)); + } + } + + private void ValidateConversationId(string conversationId) + { + if (string.IsNullOrWhiteSpace(conversationId)) + { + throw new ArgumentException($"Invalid conversationId {conversationId}."); + } + } + + private void ValidateMessageId(string messageId) + { + if (string.IsNullOrWhiteSpace(messageId)) + { + throw new ArgumentException($"Invalid messageId {messageId}"); + } + } + + private void ValidateLimit(int limit) + { + if (limit <= 0) + { + throw new ArgumentException($"Invalid limit {limit}. Limit must be greater or equal to 1."); + } + } + + private void ValidateLastSeenMessageTime(long lastSeenMessageTime) + { + if (lastSeenMessageTime < 0) + { + throw new ArgumentException( + $"Invalid lastSeenMessageTime {lastSeenMessageTime}. LastSeenMessageTime must be greater or equal to 0."); + } + } +} \ No newline at end of file diff --git a/ChatService.Web/Storage/CosmosProfileStore.cs b/ChatService.Web/Storage/CosmosProfileStore.cs new file mode 100644 index 0000000..9ab18be --- /dev/null +++ b/ChatService.Web/Storage/CosmosProfileStore.cs @@ -0,0 +1,125 @@ +using System.Net; +using ChatService.Web.Dtos; +using ChatService.Web.Exceptions; +using ChatService.Web.Storage.Entities; +using ChatService.Web.Utilities; +using Microsoft.Azure.Cosmos; + +namespace ChatService.Web.Storage; + +public class CosmosProfileStore : IProfileStore +{ + private readonly CosmosClient _cosmosClient; + + public CosmosProfileStore(CosmosClient cosmosClient) + { + _cosmosClient = cosmosClient; + } + + private Container Container => _cosmosClient.GetDatabase("chatService").GetContainer("sharedContainer"); + + public async Task AddProfile(Profile profile) + { + ValidateProfile(profile); + + try + { + await Container.CreateItemAsync(ToEntity(profile), new PartitionKey(profile.Username)); + } + catch (CosmosException e) + { + if (e.StatusCode == HttpStatusCode.Conflict) + { + throw new UsernameTakenException($"A profile with username {profile.Username} already exists."); + } + ServiceAvailabilityCheckerUtilities.ThrowIfCosmosUnavailable(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) + { + return null; + } + ServiceAvailabilityCheckerUtilities.ThrowIfCosmosUnavailable(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; + } + ServiceAvailabilityCheckerUtilities.ThrowIfCosmosUnavailable(e); + throw; + } + } + + public async Task ProfileExists(string username) + { + Profile? profile = await GetProfile(username); + + return profile != null; + } + + private static ProfileEntity ToEntity(Profile profile) + { + return new ProfileEntity( + partitionKey: profile.Username, + id: profile.Username, + profile.FirstName, + profile.LastName, + profile.ProfilePictureId + ); + } + + private static Profile ToProfile(ProfileEntity entity) + { + return new Profile + { + Username = entity.id, + FirstName = entity.FirstName, + LastName = entity.LastName, + ProfilePictureId = entity.ProfilePictureId + }; + } + + private void ValidateProfile(Profile profile) + { + if (profile == null || + string.IsNullOrWhiteSpace(profile.Username) || + string.IsNullOrWhiteSpace(profile.FirstName) || + string.IsNullOrWhiteSpace(profile.LastName) + // string.IsNullOrWhiteSpace(profile.ProfilePictureId) + ) + { + throw new ArgumentException($"Invalid profile {profile}", nameof(profile)); + } + } +} \ No newline at end of file diff --git a/ChatService.Web/Storage/CosmosUserConversationStore.cs b/ChatService.Web/Storage/CosmosUserConversationStore.cs new file mode 100644 index 0000000..e4ebe32 --- /dev/null +++ b/ChatService.Web/Storage/CosmosUserConversationStore.cs @@ -0,0 +1,204 @@ +using System.Net; +using System.Reflection.PortableExecutable; +using ChatService.Web.Dtos; +using ChatService.Web.Enums; +using ChatService.Web.Exceptions; +using ChatService.Web.Storage.Entities; +using ChatService.Web.Utilities; +using Microsoft.Azure.Cosmos; +using Microsoft.Azure.Cosmos.Linq; + +namespace ChatService.Web.Storage; + +public class CosmosUserConversationStore : IUserConversationStore +{ + private readonly CosmosClient _cosmosClient; + + public CosmosUserConversationStore(CosmosClient cosmosClient) + { + _cosmosClient = cosmosClient; + } + + private Container Container => _cosmosClient.GetDatabase("chatService").GetContainer("sharedContainer"); + + public async Task UpsertUserConversation(UserConversation userConversation) + { + ValidateUserConversation(userConversation); + + try + { + await Container.UpsertItemAsync(ToEntity(userConversation), new PartitionKey(userConversation.Username)); + } + catch (CosmosException e) + { + ServiceAvailabilityCheckerUtilities.ThrowIfCosmosUnavailable(e); + throw; + } + } + + public async Task GetUserConversation(string username, string conversationId) + { + ValidateUsername(username); + ValidateConversationId(conversationId); + + try + { + var entity = await Container.ReadItemAsync( + id: conversationId, + partitionKey: new PartitionKey(username), + new ItemRequestOptions + { + ConsistencyLevel = ConsistencyLevel.Session + } + ); + return ToUserConversation(entity); + } + catch (CosmosException e) + { + if (e.StatusCode == HttpStatusCode.NotFound) + { + return null; + } + ServiceAvailabilityCheckerUtilities.ThrowIfCosmosUnavailable(e); + throw; + } + } + + public async Task GetUserConversations(string username, + GetUserConversationsParameters parameters) + { + ValidateUsername(username); + ValidateLimit(parameters.Limit); + ValidateLastSeenMessageTime(parameters.LastSeenConversationTime); + + List userConversations = new(); + string? nextContinuationToken = null; + + QueryRequestOptions options = new(); + options.MaxItemCount = parameters.Limit; + + try + { + IQueryable query = Container + .GetItemLinqQueryable( + allowSynchronousQueryExecution: false, parameters.ContinuationToken, options) + .Where(e => e.partitionKey == username && e.LastModifiedTime > parameters.LastSeenConversationTime); + + if (parameters.Order == OrderBy.ASC) + { + query = query.OrderBy(e => e.LastModifiedTime); + } + else + { + query = query.OrderByDescending(e => e.LastModifiedTime); + } + + using (FeedIterator iterator = query.ToFeedIterator()) + { + FeedResponse response = await iterator.ReadNextAsync(); + var receivedUserConversations = response.Select(ToUserConversation); + + userConversations.AddRange(receivedUserConversations); + + nextContinuationToken = response.ContinuationToken; + }; + + return new GetUserConversationsResult + { + UserConversations = userConversations, + NextContinuationToken = nextContinuationToken + }; + } + catch (CosmosException e) + { + if (e.StatusCode == HttpStatusCode.BadRequest) + { + throw new InvalidContinuationTokenException($"Continuation token {parameters.ContinuationToken} is invalid."); + } + ServiceAvailabilityCheckerUtilities.ThrowIfCosmosUnavailable(e); + throw; + } + } + + public async Task DeleteUserConversation(string username, string conversationId) + { + try + { + await Container.DeleteItemAsync( + id: conversationId, + partitionKey: new PartitionKey(username)); + } + catch (CosmosException e) + { + if (e.StatusCode == HttpStatusCode.NotFound) + { + return; + } + ServiceAvailabilityCheckerUtilities.ThrowIfCosmosUnavailable(e); + throw; + } + } + + private static UserConversationEntity ToEntity(UserConversation userConversation) + { + return new UserConversationEntity( + partitionKey: userConversation.Username, + id: userConversation.ConversationId, + userConversation.LastModifiedTime + ); + } + + private static UserConversation ToUserConversation(UserConversationEntity entity) + { + return new UserConversation { + Username = entity.partitionKey, + ConversationId = entity.id, + LastModifiedTime = entity.LastModifiedTime + }; + } + + private void ValidateUserConversation(UserConversation userConversation) + { + if (userConversation == null || + string.IsNullOrWhiteSpace(userConversation.Username) || + string.IsNullOrWhiteSpace(userConversation.ConversationId) || + userConversation.LastModifiedTime < 0 + ) + { + throw new ArgumentException($"Invalid user conversation {userConversation}", nameof(userConversation)); + } + } + + private void ValidateUsername(string username) + { + if (string.IsNullOrWhiteSpace(username)) + { + throw new ArgumentException($"Invalid username {username}"); + } + } + + private void ValidateConversationId(string conversationId) + { + if (string.IsNullOrWhiteSpace(conversationId)) + { + throw new ArgumentException($"Invalid conversationId {conversationId}."); + } + } + + private void ValidateLimit(int limit) + { + if (limit <= 0) + { + throw new ArgumentException($"Invalid limit {limit}. Limit must be greater or equal to 1."); + } + } + + private void ValidateLastSeenMessageTime(long lastSeenConversationTime) + { + if (lastSeenConversationTime < 0) + { + throw new ArgumentException( + $"Invalid lastSeenConversationTime {lastSeenConversationTime}. LastSeenConversationTime must be greater or equal to 0."); + } + } +} \ No newline at end of file diff --git a/ChatService.Web/Storage/Entities/MessageEntity.cs b/ChatService.Web/Storage/Entities/MessageEntity.cs new file mode 100644 index 0000000..f961f81 --- /dev/null +++ b/ChatService.Web/Storage/Entities/MessageEntity.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Storage.Entities; + +public record MessageEntity( + string partitionKey, + string id, + long UnixTime, + string SenderUsername, + string Text); \ No newline at end of file diff --git a/ChatService.Web/Storage/Entities/ProfileEntity.cs b/ChatService.Web/Storage/Entities/ProfileEntity.cs new file mode 100644 index 0000000..f7b41f3 --- /dev/null +++ b/ChatService.Web/Storage/Entities/ProfileEntity.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Storage.Entities; + +public record ProfileEntity( + string partitionKey, + string id, + string FirstName, + string LastName, + string ProfilePictureId); diff --git a/ChatService.Web/Storage/Entities/UserConversationEntity.cs b/ChatService.Web/Storage/Entities/UserConversationEntity.cs new file mode 100644 index 0000000..bd39b7d --- /dev/null +++ b/ChatService.Web/Storage/Entities/UserConversationEntity.cs @@ -0,0 +1,6 @@ +namespace ChatService.Web.Storage.Entities; + +public record UserConversationEntity( + string partitionKey, + string id, + long LastModifiedTime); \ No newline at end of file diff --git a/ChatService.Web/Storage/IImageStore.cs b/ChatService.Web/Storage/IImageStore.cs new file mode 100644 index 0000000..2d5b31e --- /dev/null +++ b/ChatService.Web/Storage/IImageStore.cs @@ -0,0 +1,11 @@ +using ChatService.Web.Dtos; + +namespace ChatService.Web.Storage; + +public interface IImageStore +{ + Task UploadImage(Image image); + Task DownloadImage(string id); + Task DeleteImage(string id); + Task ImageExists(string id); +} \ No newline at end of file diff --git a/ChatService.Web/Storage/IMessageStore.cs b/ChatService.Web/Storage/IMessageStore.cs new file mode 100644 index 0000000..daebb94 --- /dev/null +++ b/ChatService.Web/Storage/IMessageStore.cs @@ -0,0 +1,14 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Enums; + +namespace ChatService.Web.Storage; + +public interface IMessageStore +{ + Task AddMessage(string conversationId, Message message); + Task GetMessage(string conversationId, string messageId); + Task GetMessages(string conversationId, GetMessagesParameters parameters); + Task ConversationExists(string conversationId); + Task DeleteMessage(string conversationId, string messageId); + Task UpdateMessageTime(string conversationId, Message message); +} diff --git a/ChatService.Web/Storage/IProfileStore.cs b/ChatService.Web/Storage/IProfileStore.cs new file mode 100644 index 0000000..a0104f7 --- /dev/null +++ b/ChatService.Web/Storage/IProfileStore.cs @@ -0,0 +1,11 @@ +using ChatService.Web.Dtos; + +namespace ChatService.Web.Storage; + +public interface IProfileStore +{ + Task AddProfile(Profile profile); + Task GetProfile(string username); + Task DeleteProfile(string username); + Task ProfileExists(string username); +} \ No newline at end of file diff --git a/ChatService.Web/Storage/IUserConversationStore.cs b/ChatService.Web/Storage/IUserConversationStore.cs new file mode 100644 index 0000000..59c3c8c --- /dev/null +++ b/ChatService.Web/Storage/IUserConversationStore.cs @@ -0,0 +1,11 @@ +using ChatService.Web.Dtos; + +namespace ChatService.Web.Storage; + +public interface IUserConversationStore +{ + Task UpsertUserConversation(UserConversation userConversation); + Task GetUserConversation(string username, string conversationId); + Task GetUserConversations(string username, GetUserConversationsParameters parameters); + Task DeleteUserConversation(string username, string conversationId); +} \ No newline at end of file diff --git a/ChatService.Web/Utilities/ConversationIdUtilities.cs b/ChatService.Web/Utilities/ConversationIdUtilities.cs new file mode 100644 index 0000000..6d56323 --- /dev/null +++ b/ChatService.Web/Utilities/ConversationIdUtilities.cs @@ -0,0 +1,38 @@ +using ChatService.Web.Exceptions; + +namespace ChatService.Web.Utilities; + +public class ConversationIdUtilities +{ + private static readonly char Seperator = '_'; + + public static string GenerateConversationId(string username1, string username2) + { + if (username1.CompareTo(username2) < 0) + { + return username1 + Seperator + username2; + } + return username2 + Seperator + username1; + } + + public static string[] SplitConversationId(string conversationId) + { + return conversationId.Split(Seperator); + } + + public static void ValidateConversationId(string conversationId) + { + if (string.IsNullOrWhiteSpace(conversationId) || !conversationId.Contains(Seperator)) + { + throw new ArgumentException($"Invalid conversationId {conversationId}."); + } + } + + public static void ValidateUsernameSeparator(string username) + { + if (username.Contains(Seperator)) + { + throw new InvalidUsernameException($"Username {username} is invalid. Usernames cannot have an underscore."); + } + } +} \ No newline at end of file diff --git a/ChatService.Web/Utilities/ServiceAvailabilityCheckerUtilities.cs b/ChatService.Web/Utilities/ServiceAvailabilityCheckerUtilities.cs new file mode 100644 index 0000000..c86bf1c --- /dev/null +++ b/ChatService.Web/Utilities/ServiceAvailabilityCheckerUtilities.cs @@ -0,0 +1,26 @@ +using System.Net; +using Azure; +using ChatService.Web.Exceptions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.Cosmos; + +namespace ChatService.Web.Utilities; + +public class ServiceAvailabilityCheckerUtilities +{ + public static void ThrowIfCosmosUnavailable(CosmosException e) + { + if (e.StatusCode == HttpStatusCode.ServiceUnavailable) + { + throw new CosmosServiceUnavailableException(e.Message); + } + } + + public static void ThrowIfBlobUnavailable(RequestFailedException e) + { + if (e.Status == 503) + { + throw new BlobServiceUnavailableException(e.Message); + } + } +} \ No newline at end of file diff --git a/ChatService.Web/appsettings.Development.json b/ChatService.Web/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/ChatService.Web/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/ChatService.Web/appsettings.json b/ChatService.Web/appsettings.json new file mode 100644 index 0000000..8f69ef1 --- /dev/null +++ b/ChatService.Web/appsettings.json @@ -0,0 +1,23 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Trace", + "Microsoft.AspNetCore": "Warning" + }, + "ApplicationInsights": { + "LogLevel": { + "Default": "Trace" + } + } + }, + + "Cosmos": { + "ConnectionString": "" + }, + + "BlobStorage": { + "ConnectionString": "" + }, + + "AllowedHosts": "*" +} diff --git a/ChatService.sln b/ChatService.sln new file mode 100644 index 0000000..d096d46 --- /dev/null +++ b/ChatService.sln @@ -0,0 +1,28 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatService.Web", "ChatService.Web\ChatService.Web.csproj", "{61F5D341-3097-447A-AC42-28C9664BC863}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatService.Web.Tests", "ChatService.Web.Tests\ChatService.Web.Tests.csproj", "{270C3052-C9D2-4EF9-9683-D5E4A4E69733}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatService.Web.IntegrationTests", "ChatService.Web.IntegrationTests\ChatService.Web.IntegrationTests.csproj", "{A7808CB8-B561-4939-9B4E-8AC0FD786DC3}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {61F5D341-3097-447A-AC42-28C9664BC863}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {61F5D341-3097-447A-AC42-28C9664BC863}.Debug|Any CPU.Build.0 = Debug|Any CPU + {61F5D341-3097-447A-AC42-28C9664BC863}.Release|Any CPU.ActiveCfg = Release|Any CPU + {61F5D341-3097-447A-AC42-28C9664BC863}.Release|Any CPU.Build.0 = Release|Any CPU + {270C3052-C9D2-4EF9-9683-D5E4A4E69733}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {270C3052-C9D2-4EF9-9683-D5E4A4E69733}.Debug|Any CPU.Build.0 = Debug|Any CPU + {270C3052-C9D2-4EF9-9683-D5E4A4E69733}.Release|Any CPU.ActiveCfg = Release|Any CPU + {270C3052-C9D2-4EF9-9683-D5E4A4E69733}.Release|Any CPU.Build.0 = Release|Any CPU + {A7808CB8-B561-4939-9B4E-8AC0FD786DC3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A7808CB8-B561-4939-9B4E-8AC0FD786DC3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A7808CB8-B561-4939-9B4E-8AC0FD786DC3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A7808CB8-B561-4939-9B4E-8AC0FD786DC3}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal