From 916658747e83f6850b95fa43376c5e88a0a7aca0 Mon Sep 17 00:00:00 2001 From: nadimakk Date: Sat, 18 Feb 2023 17:00:32 +0200 Subject: [PATCH 01/45] implement profiles APIs + tests --- .gitignore | 5 + .idea/.idea.ChatService/.idea/.gitignore | 13 +++ .idea/.idea.ChatService/.idea/indexLayout.xml | 8 ++ .idea/.idea.ChatService/.idea/vcs.xml | 6 ++ .../ChatService.Web.IntegrationTests.csproj | 29 ++++++ .../CosmosProfileStoreTests.cs | 81 +++++++++++++++ ChatService.Web.IntegrationTests/Usings.cs | 1 + .../ChatService.Web.Tests.csproj | 30 ++++++ .../Controllers/ProfileControllerTests.cs | 88 +++++++++++++++++ ChatService.Web.Tests/Usings.cs | 1 + ChatService.Web/ChatService.Web.csproj | 14 +++ .../Configuration/CosmosSettings.cs | 6 ++ .../Controllers/ImageController.cs | 6 ++ .../Controllers/ProfileController.cs | 43 ++++++++ ChatService.Web/Dtos/Profile.cs | 3 + ChatService.Web/Dtos/UploadImageRequest.cs | 6 ++ ChatService.Web/Program.cs | 41 ++++++++ .../Properties/launchSettings.json | 31 ++++++ ChatService.Web/Storage/CosmosProfileStore.cs | 98 +++++++++++++++++++ .../Storage/Entities/ProfileEntity.cs | 3 + ChatService.Web/Storage/IImageStore.cs | 6 ++ ChatService.Web/Storage/IProfileStore.cs | 10 ++ ChatService.Web/appsettings.Development.json | 8 ++ ChatService.Web/appsettings.json | 14 +++ ChatService.sln | 16 +++ ChatService.sln.DotSettings.user | 10 ++ 26 files changed, 577 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.idea.ChatService/.idea/.gitignore create mode 100644 .idea/.idea.ChatService/.idea/indexLayout.xml create mode 100644 .idea/.idea.ChatService/.idea/vcs.xml create mode 100644 ChatService.Web.IntegrationTests/ChatService.Web.IntegrationTests.csproj create mode 100644 ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs create mode 100644 ChatService.Web.IntegrationTests/Usings.cs create mode 100644 ChatService.Web.Tests/ChatService.Web.Tests.csproj create mode 100644 ChatService.Web.Tests/Controllers/ProfileControllerTests.cs create mode 100644 ChatService.Web.Tests/Usings.cs create mode 100644 ChatService.Web/ChatService.Web.csproj create mode 100644 ChatService.Web/Configuration/CosmosSettings.cs create mode 100644 ChatService.Web/Controllers/ImageController.cs create mode 100644 ChatService.Web/Controllers/ProfileController.cs create mode 100644 ChatService.Web/Dtos/Profile.cs create mode 100644 ChatService.Web/Dtos/UploadImageRequest.cs create mode 100644 ChatService.Web/Program.cs create mode 100644 ChatService.Web/Properties/launchSettings.json create mode 100644 ChatService.Web/Storage/CosmosProfileStore.cs create mode 100644 ChatService.Web/Storage/Entities/ProfileEntity.cs create mode 100644 ChatService.Web/Storage/IImageStore.cs create mode 100644 ChatService.Web/Storage/IProfileStore.cs create mode 100644 ChatService.Web/appsettings.Development.json create mode 100644 ChatService.Web/appsettings.json create mode 100644 ChatService.sln create mode 100644 ChatService.sln.DotSettings.user diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..add57be --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +/packages/ +riderModule.iml +/_ReSharper.Caches/ \ No newline at end of file diff --git a/.idea/.idea.ChatService/.idea/.gitignore b/.idea/.idea.ChatService/.idea/.gitignore new file mode 100644 index 0000000..68a553e --- /dev/null +++ b/.idea/.idea.ChatService/.idea/.gitignore @@ -0,0 +1,13 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/contentModel.xml +/.idea.ChatService.iml +/modules.xml +/projectSettingsUpdater.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/.idea.ChatService/.idea/indexLayout.xml b/.idea/.idea.ChatService/.idea/indexLayout.xml new file mode 100644 index 0000000..7b08163 --- /dev/null +++ b/.idea/.idea.ChatService/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/.idea.ChatService/.idea/vcs.xml b/.idea/.idea.ChatService/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/.idea.ChatService/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/ChatService.Web.IntegrationTests/ChatService.Web.IntegrationTests.csproj b/ChatService.Web.IntegrationTests/ChatService.Web.IntegrationTests.csproj new file mode 100644 index 0000000..43fb867 --- /dev/null +++ b/ChatService.Web.IntegrationTests/ChatService.Web.IntegrationTests.csproj @@ -0,0 +1,29 @@ + + + + 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/CosmosProfileStoreTests.cs b/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs new file mode 100644 index 0000000..a5de51c --- /dev/null +++ b/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs @@ -0,0 +1,81 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Storage; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace ChatService.Web.IntegrationTests; + + +public class CosmosProfileStoreTest : IClassFixture>, IAsyncLifetime +{ + private readonly IProfileStore _store; + + private readonly Profile _profile = new( + username: Guid.NewGuid().ToString(), + firstName: "Foo", + lastName: "Bar", + profilePictureId: "123" + ); + + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + await _store.DeleteProfile(_profile.username); + } + + public CosmosProfileStoreTest(WebApplicationFactory factory) + { + _store = factory.Services.GetRequiredService(); + } + + [Fact] + public async Task AddNewProfile_Success() + { + await _store.AddProfile(_profile); + Assert.Equal(_profile, await _store.GetProfile(_profile.username)); + } + + [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 AddNewProfile_InvalidArgs(string username, string firstName, string lastName, string profilePictureId) + { + Profile profile = new(username, firstName, lastName, profilePictureId); + await Assert.ThrowsAsync( async () => await _store.AddProfile(profile)); + } + + [Fact] + public async Task AddNewProfile_NullProfile() + { + await Assert.ThrowsAsync( async () => await _store.AddProfile(null)); + } + + [Fact] + public async Task GetNonExistingProfile() + { + Assert.Null(await _store.GetProfile(_profile.username)); + } + + [Fact] + public async Task DeleteProfile() + { + await _store.AddProfile(_profile); + Assert.Equal(_profile, await _store.GetProfile(_profile.username)); + await _store.DeleteProfile(_profile.username); + Assert.Null(await _store.GetProfile(_profile.username)); + } +} \ No newline at end of file diff --git a/ChatService.Web.IntegrationTests/Usings.cs b/ChatService.Web.IntegrationTests/Usings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/ChatService.Web.IntegrationTests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/ChatService.Web.Tests/ChatService.Web.Tests.csproj b/ChatService.Web.Tests/ChatService.Web.Tests.csproj new file mode 100644 index 0000000..2e74889 --- /dev/null +++ b/ChatService.Web.Tests/ChatService.Web.Tests.csproj @@ -0,0 +1,30 @@ + + + + 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/ProfileControllerTests.cs b/ChatService.Web.Tests/Controllers/ProfileControllerTests.cs new file mode 100644 index 0000000..79ec7b8 --- /dev/null +++ b/ChatService.Web.Tests/Controllers/ProfileControllerTests.cs @@ -0,0 +1,88 @@ +using System.Net; +using System.Net.Http.Json; +using ChatService.Web.Dtos; +using ChatService.Web.Storage; +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 ProfileControllerTests : IClassFixture> +{ + private readonly Mock _profileStoreMock = new(); + private readonly HttpClient _httpClient; + private readonly Profile _profile = new Profile("foobar", "Foo", "Bar", "123"); + + public ProfileControllerTests(WebApplicationFactory factory) + { + _httpClient = factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => { services.AddSingleton(_profileStoreMock.Object); }); + }).CreateClient(); + } + + [Fact] + public async Task GetProfile_Success() + { + _profileStoreMock.Setup(m => m.GetProfile(_profile.username)) + .ReturnsAsync(_profile); + + var response = await _httpClient.GetAsync($"/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() + { + _profileStoreMock.Setup(m => m.GetProfile(_profile.username)) + .ReturnsAsync((Profile?) null); + + var response = await _httpClient.GetAsync($"/Profile/{_profile.username}"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + + var json = await response.Content.ReadAsStringAsync(); + Assert.Equal($"A profile with the username {_profile.username} was not found.", json); + } + + [Fact] + public async Task PostProfile_Success() + { + _profileStoreMock.Setup(m => m.GetProfile(_profile.username)) + .ReturnsAsync((Profile?) null); + + var response = await _httpClient.PostAsJsonAsync("/Profile/", _profile); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + var json = await response.Content.ReadAsStringAsync(); + var receivedProfile = JsonConvert.DeserializeObject(json); + Assert.Equal(_profile, receivedProfile); + + _profileStoreMock.Verify(mock => mock.AddProfile(_profile), Times.Once); + } + + [Fact] + public async Task PostProfile_UsernameTaken() + { + _profileStoreMock.Setup(m => m.GetProfile(_profile.username)) + .ReturnsAsync(_profile); + + var response = await _httpClient.PostAsJsonAsync("/Profile/", _profile); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + + var json = await response.Content.ReadAsStringAsync(); + Assert.Equal($"A user with the username {_profile.username} already exist.", json); + + _profileStoreMock.Verify(mock => mock.AddProfile(_profile), Times.Never); + } +} \ 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..055ca8e --- /dev/null +++ b/ChatService.Web/ChatService.Web.csproj @@ -0,0 +1,14 @@ + + + + net6.0 + enable + enable + + + + + + + + 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/ImageController.cs b/ChatService.Web/Controllers/ImageController.cs new file mode 100644 index 0000000..df59f68 --- /dev/null +++ b/ChatService.Web/Controllers/ImageController.cs @@ -0,0 +1,6 @@ +namespace ChatService.Web.Controllers; + +public class ImageController +{ + +} \ 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..62eb788 --- /dev/null +++ b/ChatService.Web/Controllers/ProfileController.cs @@ -0,0 +1,43 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Storage; +using Microsoft.AspNetCore.Mvc; + +namespace ChatService.Web.Controllers; + +[ApiController] +[Route("[controller]")] +public class ProfileController : ControllerBase +{ + private readonly IProfileStore _profileStore; + + public ProfileController(IProfileStore profileStore) + { + _profileStore = profileStore; + } + + + [HttpGet("{username}")] + public async Task> GetProfile(string username) + { + var profile = await _profileStore.GetProfile(username); + if (profile == null) + { + return NotFound($"A profile with the username {username} was not found."); + } + + return Ok(profile); + } + + [HttpPost] + public async Task> PostProfile(Profile profile) + { + var isUsernameTaken = await _profileStore.GetProfile(profile.username) != null; + if (isUsernameTaken) + { + return Conflict($"A user with the username {profile.username} already exist."); + } + + await _profileStore.AddProfile(profile); + return CreatedAtAction(nameof(GetProfile), new { username = profile.username }, profile); + } +} \ 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..bd5d7b1 --- /dev/null +++ b/ChatService.Web/Dtos/Profile.cs @@ -0,0 +1,3 @@ +namespace ChatService.Web.Dtos; + +public record Profile(); \ 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/Program.cs b/ChatService.Web/Program.cs new file mode 100644 index 0000000..d8116dd --- /dev/null +++ b/ChatService.Web/Program.cs @@ -0,0 +1,41 @@ +using ChatService.Web.Configuration; +using ChatService.Web.Storage; +using Microsoft.Azure.Cosmos; +using Microsoft.Extensions.Options; + +var builder = WebApplication.CreateBuilder(args); + +// Add Configuration +builder.Services.Configure(builder.Configuration.GetSection("Cosmos")); + +// Add services to the container. +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => +{ + var cosmosOptions = sp.GetRequiredService>(); + return new CosmosClient(cosmosOptions.Value.ConnectionString); +}); + +builder.Services.AddControllers(); +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); + +public partial class Program { } \ No newline at end of file diff --git a/ChatService.Web/Properties/launchSettings.json b/ChatService.Web/Properties/launchSettings.json new file mode 100644 index 0000000..9f50bb5 --- /dev/null +++ b/ChatService.Web/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:61550", + "sslPort": 44359 + } + }, + "profiles": { + "ChatService.Web": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7185;http://localhost:5157", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/ChatService.Web/Storage/CosmosProfileStore.cs b/ChatService.Web/Storage/CosmosProfileStore.cs new file mode 100644 index 0000000..de21ea3 --- /dev/null +++ b/ChatService.Web/Storage/CosmosProfileStore.cs @@ -0,0 +1,98 @@ +using System.Net; +using ChatService.Web.Dtos; +using ChatService.Web.Storage.Entities; +using Microsoft.Azure.Cosmos; + +namespace ChatService.Web.Storage; + +public class CosmosProfileStore : IProfileStore +{ + private readonly CosmosClient _cosmosClient; + + public CosmosProfileStore(CosmosClient cosmosClient) + { + _cosmosClient = cosmosClient; + } + + private Container Container => _cosmosClient.GetDatabase("chatService").GetContainer("profiles"); + + public async Task AddProfile(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)); + } + + await Container.UpsertItemAsync(ToEntity(profile)); + } + + public async Task GetProfile(string username) + { + try + { + var entity = await Container.ReadItemAsync( + id: username, + partitionKey: new PartitionKey(username), + new ItemRequestOptions + { + ConsistencyLevel = ConsistencyLevel.Session + } + ); + return ToProfile(entity); + } + catch (CosmosException e) + { + if (e.StatusCode == HttpStatusCode.NotFound) + { + return null; + } + throw; + } + } + + public async Task DeleteProfile(string username) + { + try + { + await Container.DeleteItemAsync( + id: username, + partitionKey: new PartitionKey(username) + ); + } + catch (CosmosException e) + { + if (e.StatusCode == HttpStatusCode.NotFound) + { + return; + } + throw; + } + } + + + 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, + entity.firstName, + entity.lastName, + entity.profilePictureId + ); + } +} \ 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..abc1d1a --- /dev/null +++ b/ChatService.Web/Storage/Entities/ProfileEntity.cs @@ -0,0 +1,3 @@ +namespace ChatService.Web.Storage.Entities; + +public record ProfileEntity(string partitionKey, string id, string firstName, string lastName, string profilePictureId); diff --git a/ChatService.Web/Storage/IImageStore.cs b/ChatService.Web/Storage/IImageStore.cs new file mode 100644 index 0000000..ebfc029 --- /dev/null +++ b/ChatService.Web/Storage/IImageStore.cs @@ -0,0 +1,6 @@ +namespace ChatService.Web.Storage; + +public interface IImageStore +{ + +} \ No newline at end of file diff --git a/ChatService.Web/Storage/IProfileStore.cs b/ChatService.Web/Storage/IProfileStore.cs new file mode 100644 index 0000000..cdc7ab9 --- /dev/null +++ b/ChatService.Web/Storage/IProfileStore.cs @@ -0,0 +1,10 @@ +using ChatService.Web.Dtos; + +namespace ChatService.Web.Storage; + +public interface IProfileStore +{ + Task AddProfile(Profile profile); + Task GetProfile(string username); + Task DeleteProfile(string username); +} \ 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..5e42d81 --- /dev/null +++ b/ChatService.Web/appsettings.json @@ -0,0 +1,14 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + + "Cosmos": { + "ConnectionString": "AccountEndpoint=https://chat-service.documents.azure.com:443/;AccountKey=bTPHlotBT0IYyYa4sFscBdFm3dzVpTexbtk8ygqhytijFYLpJAByf5CThrODbuDVvmTOcDio2ywEACDbCYlcOg==;" + }, + + "AllowedHosts": "*" +} diff --git a/ChatService.sln b/ChatService.sln new file mode 100644 index 0000000..6e8e9e7 --- /dev/null +++ b/ChatService.sln @@ -0,0 +1,16 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatService", "ChatService\ChatService.csproj", "{80FCF519-CA73-447D-A58F-D9873D43B32E}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {80FCF519-CA73-447D-A58F-D9873D43B32E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {80FCF519-CA73-447D-A58F-D9873D43B32E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {80FCF519-CA73-447D-A58F-D9873D43B32E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {80FCF519-CA73-447D-A58F-D9873D43B32E}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/ChatService.sln.DotSettings.user b/ChatService.sln.DotSettings.user new file mode 100644 index 0000000..47156fc --- /dev/null +++ b/ChatService.sln.DotSettings.user @@ -0,0 +1,10 @@ + + <SessionState ContinuousTestingMode="0" IsActive="True" Name="GetProfile_Success" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <TestAncestor> + <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ProfileControllerTests.GetProfile_Success</TestId> + <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ProfileControllerTests.GetProfile_ProfileNotFound</TestId> + <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ProfileControllerTests.PostProfile_Success</TestId> + <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ProfileControllerTests.PostProfile_UsernameTaken</TestId> + <TestId>xUnit::A7808CB8-B561-4939-9B4E-8AC0FD786DC3::net6.0::ChatService.Web.IntegrationTests.CosmosProfileStoreTest</TestId> + </TestAncestor> +</SessionState> \ No newline at end of file From 6867305f545eef92d2141928b845afd533d6f6b3 Mon Sep 17 00:00:00 2001 From: nadimakk Date: Sat, 18 Feb 2023 21:24:03 +0200 Subject: [PATCH 02/45] start of images implementation --- .../ChatService.Web.IntegrationTests.csproj | 1 + .../ChatService.Web.Tests.csproj | 1 + .../Controllers/ProfileControllerTests.cs | 23 +++++++++++ ChatService.Web/ChatService.Web.csproj | 1 + ChatService.Web/Configuration/BlobSettings.cs | 7 ++++ .../Controllers/ImageController.cs | 15 ++++++- .../Controllers/ProfileController.cs | 1 + ChatService.Web/Dtos/Profile.cs | 8 +++- ChatService.Web/Program.cs | 8 ++++ ChatService.Web/Storage/BlobImageStore.cs | 40 +++++++++++++++++++ ChatService.Web/Storage/IImageStore.cs | 6 ++- ChatService.Web/appsettings.json | 5 +++ ChatService.sln | 22 +++++++--- ChatService.sln.DotSettings.user | 1 + 14 files changed, 130 insertions(+), 9 deletions(-) create mode 100644 ChatService.Web/Configuration/BlobSettings.cs create mode 100644 ChatService.Web/Storage/BlobImageStore.cs diff --git a/ChatService.Web.IntegrationTests/ChatService.Web.IntegrationTests.csproj b/ChatService.Web.IntegrationTests/ChatService.Web.IntegrationTests.csproj index 43fb867..de692c2 100644 --- a/ChatService.Web.IntegrationTests/ChatService.Web.IntegrationTests.csproj +++ b/ChatService.Web.IntegrationTests/ChatService.Web.IntegrationTests.csproj @@ -9,6 +9,7 @@ + diff --git a/ChatService.Web.Tests/ChatService.Web.Tests.csproj b/ChatService.Web.Tests/ChatService.Web.Tests.csproj index 2e74889..1aa9611 100644 --- a/ChatService.Web.Tests/ChatService.Web.Tests.csproj +++ b/ChatService.Web.Tests/ChatService.Web.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/ChatService.Web.Tests/Controllers/ProfileControllerTests.cs b/ChatService.Web.Tests/Controllers/ProfileControllerTests.cs index 79ec7b8..7121ea5 100644 --- a/ChatService.Web.Tests/Controllers/ProfileControllerTests.cs +++ b/ChatService.Web.Tests/Controllers/ProfileControllerTests.cs @@ -85,4 +85,27 @@ public async Task PostProfile_UsernameTaken() _profileStoreMock.Verify(mock => mock.AddProfile(_profile), Times.Never); } + + [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) + { + Profile profile = new(username, firstName, lastName, profilePictureId); + + var response = await _httpClient.PutAsJsonAsync("/Profile", profile); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + _profileStoreMock.Verify(mock => mock.AddProfile(_profile), Times.Never); + } } \ No newline at end of file diff --git a/ChatService.Web/ChatService.Web.csproj b/ChatService.Web/ChatService.Web.csproj index 055ca8e..e2aa6fb 100644 --- a/ChatService.Web/ChatService.Web.csproj +++ b/ChatService.Web/ChatService.Web.csproj @@ -7,6 +7,7 @@ + diff --git a/ChatService.Web/Configuration/BlobSettings.cs b/ChatService.Web/Configuration/BlobSettings.cs new file mode 100644 index 0000000..3da4e87 --- /dev/null +++ b/ChatService.Web/Configuration/BlobSettings.cs @@ -0,0 +1,7 @@ +namespace ChatService.Web.Configuration; + +public class BlobSettings +{ + public string ConnectionString { get; init; } + public Uri ContainerUri { get; init; } +} \ No newline at end of file diff --git a/ChatService.Web/Controllers/ImageController.cs b/ChatService.Web/Controllers/ImageController.cs index df59f68..4655e75 100644 --- a/ChatService.Web/Controllers/ImageController.cs +++ b/ChatService.Web/Controllers/ImageController.cs @@ -1,6 +1,17 @@ +using ChatService.Web.Dtos; +using Microsoft.AspNetCore.Mvc; + namespace ChatService.Web.Controllers; -public class ImageController +public class ImageController : ControllerBase { - + public Task UploadImage(UploadImageRequest request) + { + throw new NotImplementedException(); + } + + public Task DownloadImage(string id) + { + throw new NotImplementedException(); + } } \ No newline at end of file diff --git a/ChatService.Web/Controllers/ProfileController.cs b/ChatService.Web/Controllers/ProfileController.cs index 62eb788..571d53d 100644 --- a/ChatService.Web/Controllers/ProfileController.cs +++ b/ChatService.Web/Controllers/ProfileController.cs @@ -19,6 +19,7 @@ public ProfileController(IProfileStore profileStore) [HttpGet("{username}")] public async Task> GetProfile(string username) { + var profile = await _profileStore.GetProfile(username); if (profile == null) { diff --git a/ChatService.Web/Dtos/Profile.cs b/ChatService.Web/Dtos/Profile.cs index bd5d7b1..b1fc984 100644 --- a/ChatService.Web/Dtos/Profile.cs +++ b/ChatService.Web/Dtos/Profile.cs @@ -1,3 +1,9 @@ +using System.ComponentModel.DataAnnotations; + namespace ChatService.Web.Dtos; -public record Profile(); \ No newline at end of file +public record Profile( + [Required] string username, + [Required] string firstName, + [Required] string lastName, + [Required] string profilePictureId); \ No newline at end of file diff --git a/ChatService.Web/Program.cs b/ChatService.Web/Program.cs index d8116dd..74c0ccd 100644 --- a/ChatService.Web/Program.cs +++ b/ChatService.Web/Program.cs @@ -1,3 +1,4 @@ +using Azure.Storage.Blobs; using ChatService.Web.Configuration; using ChatService.Web.Storage; using Microsoft.Azure.Cosmos; @@ -7,6 +8,7 @@ // Add Configuration builder.Services.Configure(builder.Configuration.GetSection("Cosmos")); +builder.Services.Configure(builder.Configuration.GetSection("Blob")); // Add services to the container. builder.Services.AddSingleton(); @@ -15,6 +17,12 @@ var cosmosOptions = sp.GetRequiredService>(); return new CosmosClient(cosmosOptions.Value.ConnectionString); }); +builder.Services.AddSingleton(sp => + { + var blobOptions = sp.GetRequiredService>(); + return new BlobContainerClient(blobOptions.Value.container); + } +); builder.Services.AddControllers(); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle diff --git a/ChatService.Web/Storage/BlobImageStore.cs b/ChatService.Web/Storage/BlobImageStore.cs new file mode 100644 index 0000000..ac2013a --- /dev/null +++ b/ChatService.Web/Storage/BlobImageStore.cs @@ -0,0 +1,40 @@ +using ChatService.Web.Dtos; + +namespace ChatService.Web.Storage; + +public class BlobImageStore : IImageStore +{ + public Task UploadImage(UploadImageRequest request) + { + throw new NotImplementedException(); + } + + public void UploadImageToBlobStorage(string containerName, string blobName) + { + // Parse the connection string and create a CloudStorageAccount object + CloudStorageAccount storageAccount = CloudStorageAccount.Parse(connectionString); + + // Create the blob client object + CloudBlobClient blobClient = storageAccount.CreateCloudBlobClient(); + + // Retrieve a reference to a container + CloudBlobContainer container = blobClient.GetContainerReference(containerName); + + // Create the container if it does not exist + container.CreateIfNotExists(); + + // Retrieve a reference to a blob + CloudBlockBlob blockBlob = container.GetBlockBlobReference(blobName); + + // Open the file and upload its data to the blob + using (var fileStream = File.OpenRead(imagePath)) + { + blockBlob.UploadFromStream(fileStream); + } + } + + public Task DownloadImage(string id) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/ChatService.Web/Storage/IImageStore.cs b/ChatService.Web/Storage/IImageStore.cs index ebfc029..8d28e97 100644 --- a/ChatService.Web/Storage/IImageStore.cs +++ b/ChatService.Web/Storage/IImageStore.cs @@ -1,6 +1,10 @@ +using ChatService.Web.Dtos; + namespace ChatService.Web.Storage; public interface IImageStore { - + Task UploadImage(UploadImageRequest request); + Task DownloadImage(string id); + } \ No newline at end of file diff --git a/ChatService.Web/appsettings.json b/ChatService.Web/appsettings.json index 5e42d81..5b8e899 100644 --- a/ChatService.Web/appsettings.json +++ b/ChatService.Web/appsettings.json @@ -10,5 +10,10 @@ "ConnectionString": "AccountEndpoint=https://chat-service.documents.azure.com:443/;AccountKey=bTPHlotBT0IYyYa4sFscBdFm3dzVpTexbtk8ygqhytijFYLpJAByf5CThrODbuDVvmTOcDio2ywEACDbCYlcOg==;" }, + "Blob": { + "ContainerUri": "https://naa137.blob.core.windows.net/images", + "ConnectionString": "" + }, + "AllowedHosts": "*" } diff --git a/ChatService.sln b/ChatService.sln index 6e8e9e7..d096d46 100644 --- a/ChatService.sln +++ b/ChatService.sln @@ -1,6 +1,10 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatService", "ChatService\ChatService.csproj", "{80FCF519-CA73-447D-A58F-D9873D43B32E}" +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 @@ -8,9 +12,17 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {80FCF519-CA73-447D-A58F-D9873D43B32E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {80FCF519-CA73-447D-A58F-D9873D43B32E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {80FCF519-CA73-447D-A58F-D9873D43B32E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {80FCF519-CA73-447D-A58F-D9873D43B32E}.Release|Any CPU.Build.0 = Release|Any CPU + {61F5D341-3097-447A-AC42-28C9664BC863}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {61F5D341-3097-447A-AC42-28C9664BC863}.Debug|Any CPU.Build.0 = Debug|Any CPU + {61F5D341-3097-447A-AC42-28C9664BC863}.Release|Any CPU.ActiveCfg = Release|Any CPU + {61F5D341-3097-447A-AC42-28C9664BC863}.Release|Any CPU.Build.0 = Release|Any CPU + {270C3052-C9D2-4EF9-9683-D5E4A4E69733}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {270C3052-C9D2-4EF9-9683-D5E4A4E69733}.Debug|Any CPU.Build.0 = Debug|Any CPU + {270C3052-C9D2-4EF9-9683-D5E4A4E69733}.Release|Any CPU.ActiveCfg = Release|Any CPU + {270C3052-C9D2-4EF9-9683-D5E4A4E69733}.Release|Any CPU.Build.0 = Release|Any CPU + {A7808CB8-B561-4939-9B4E-8AC0FD786DC3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A7808CB8-B561-4939-9B4E-8AC0FD786DC3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A7808CB8-B561-4939-9B4E-8AC0FD786DC3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A7808CB8-B561-4939-9B4E-8AC0FD786DC3}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/ChatService.sln.DotSettings.user b/ChatService.sln.DotSettings.user index 47156fc..c963ed4 100644 --- a/ChatService.sln.DotSettings.user +++ b/ChatService.sln.DotSettings.user @@ -6,5 +6,6 @@ <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ProfileControllerTests.PostProfile_Success</TestId> <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ProfileControllerTests.PostProfile_UsernameTaken</TestId> <TestId>xUnit::A7808CB8-B561-4939-9B4E-8AC0FD786DC3::net6.0::ChatService.Web.IntegrationTests.CosmosProfileStoreTest</TestId> + <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ProfileControllerTests</TestId> </TestAncestor> </SessionState> \ No newline at end of file From 106d1663a41aec294e5c2404661f20bbfa49cedb Mon Sep 17 00:00:00 2001 From: Ali Date: Sun, 19 Feb 2023 15:23:05 +0200 Subject: [PATCH 03/45] Implement ImageController & BlobImageStore --- .../Controllers/ImageControllerTests.cs | 53 +++++++++++++++++ ChatService.Web/Configuration/BlobSettings.cs | 3 +- .../Controllers/ImageController.cs | 36 ++++++++++-- ChatService.Web/Dtos/UploadImageResponse.cs | 4 ++ ChatService.Web/Program.cs | 3 +- ChatService.Web/Storage/BlobImageStore.cs | 57 +++++++++++-------- ChatService.Web/Storage/IImageStore.cs | 6 +- ChatService.Web/appsettings.json | 3 +- ChatService.sln.DotSettings.user | 7 +++ 9 files changed, 134 insertions(+), 38 deletions(-) create mode 100644 ChatService.Web.Tests/Controllers/ImageControllerTests.cs create mode 100644 ChatService.Web/Dtos/UploadImageResponse.cs diff --git a/ChatService.Web.Tests/Controllers/ImageControllerTests.cs b/ChatService.Web.Tests/Controllers/ImageControllerTests.cs new file mode 100644 index 0000000..9b84bf4 --- /dev/null +++ b/ChatService.Web.Tests/Controllers/ImageControllerTests.cs @@ -0,0 +1,53 @@ +using System.Net; +using ChatService.Web.Dtos; +using ChatService.Web.Storage; +using Microsoft.AspNetCore.Http; +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 ImageControllerTests : IClassFixture> +{ + private readonly Mock _imageStoreMock = new(); + private readonly Mock _formFileMock = new(); + private readonly HttpClient _httpClient; + + public ImageControllerTests(WebApplicationFactory factory) + { + _httpClient = factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => { services.AddSingleton(_imageStoreMock.Object); }); + }).CreateClient(); + } + + [Fact] + public async Task UploadImage_Success() + { + var imageId = Guid.NewGuid().ToString(); + var uploadImageResponse = new UploadImageResponse(imageId); + + _formFileMock.Setup(m => m.ContentType) + .Returns("image/jpg"); + _formFileMock.Setup(m => m.OpenReadStream()) + .Returns(new MemoryStream()); + _imageStoreMock.Setup(m => m.UploadImage(_formFileMock.Object)) + .ReturnsAsync(imageId); + + var content = new MultipartFormDataContent(); + var fileContent = new StreamContent(_formFileMock.Object.OpenReadStream()); + content.Add(fileContent,"File"); + + var response = await _httpClient.PostAsync("/Image", content); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var json = await response.Content.ReadAsStringAsync(); + var receivedUploadImageResponse = JsonConvert.DeserializeObject(json); + Assert.Equal(uploadImageResponse, receivedUploadImageResponse); + } + +} \ No newline at end of file diff --git a/ChatService.Web/Configuration/BlobSettings.cs b/ChatService.Web/Configuration/BlobSettings.cs index 3da4e87..cb23532 100644 --- a/ChatService.Web/Configuration/BlobSettings.cs +++ b/ChatService.Web/Configuration/BlobSettings.cs @@ -1,7 +1,6 @@ namespace ChatService.Web.Configuration; -public class BlobSettings +public record BlobSettings { public string ConnectionString { get; init; } - public Uri ContainerUri { get; init; } } \ No newline at end of file diff --git a/ChatService.Web/Controllers/ImageController.cs b/ChatService.Web/Controllers/ImageController.cs index 4655e75..c22b3de 100644 --- a/ChatService.Web/Controllers/ImageController.cs +++ b/ChatService.Web/Controllers/ImageController.cs @@ -1,17 +1,43 @@ using ChatService.Web.Dtos; +using ChatService.Web.Storage; using Microsoft.AspNetCore.Mvc; namespace ChatService.Web.Controllers; - +[ApiController] +[Route("[controller]")] public class ImageController : ControllerBase { - public Task UploadImage(UploadImageRequest request) + private readonly IImageStore _imageStore; + + public ImageController(IImageStore imageStore) { - throw new NotImplementedException(); + _imageStore = imageStore; } - public Task DownloadImage(string id) + [HttpPost] + public async Task> UploadImage([FromForm] UploadImageRequest request) + { + + string contentType = request.File.ContentType.ToLower(); + if (contentType != "image/jpg" && + contentType != "image/jpeg" && + contentType != "image/png") + { + return BadRequest($"Invalid file, must be an image."); + } + + string imageId = await _imageStore.UploadImage(request.File); + return Ok(new UploadImageResponse(imageId)); + } + + [HttpGet("{id}")] + public async Task DownloadImage(string id) { - throw new NotImplementedException(); + FileContentResult? imageResult = await _imageStore.DownloadImage(id); + if (imageResult == null) + { + return NotFound($"An image with id {id} was not found."); + } + return imageResult; } } \ 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..9989ef4 --- /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/Program.cs b/ChatService.Web/Program.cs index 74c0ccd..e7dd3e3 100644 --- a/ChatService.Web/Program.cs +++ b/ChatService.Web/Program.cs @@ -12,6 +12,7 @@ // Add services to the container. builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => { var cosmosOptions = sp.GetRequiredService>(); @@ -20,7 +21,7 @@ builder.Services.AddSingleton(sp => { var blobOptions = sp.GetRequiredService>(); - return new BlobContainerClient(blobOptions.Value.container); + return new BlobServiceClient(blobOptions.Value.ConnectionString); } ); diff --git a/ChatService.Web/Storage/BlobImageStore.cs b/ChatService.Web/Storage/BlobImageStore.cs index ac2013a..ed178be 100644 --- a/ChatService.Web/Storage/BlobImageStore.cs +++ b/ChatService.Web/Storage/BlobImageStore.cs @@ -1,40 +1,47 @@ -using ChatService.Web.Dtos; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using Microsoft.AspNetCore.Mvc; namespace ChatService.Web.Storage; public class BlobImageStore : IImageStore { - public Task UploadImage(UploadImageRequest request) + private readonly BlobServiceClient _blobServiceClient; + + public BlobImageStore(BlobServiceClient blobServiceClient) { - throw new NotImplementedException(); + _blobServiceClient = blobServiceClient; } - - public void UploadImageToBlobStorage(string containerName, string blobName) - { - // Parse the connection string and create a CloudStorageAccount object - CloudStorageAccount storageAccount = CloudStorageAccount.Parse(connectionString); - - // Create the blob client object - CloudBlobClient blobClient = storageAccount.CreateCloudBlobClient(); - - // Retrieve a reference to a container - CloudBlobContainer container = blobClient.GetContainerReference(containerName); - - // Create the container if it does not exist - container.CreateIfNotExists(); - // Retrieve a reference to a blob - CloudBlockBlob blockBlob = container.GetBlockBlobReference(blobName); + private BlobContainerClient BlobContainerClient => _blobServiceClient.GetBlobContainerClient("images"); - // Open the file and upload its data to the blob - using (var fileStream = File.OpenRead(imagePath)) + public async Task UploadImage(IFormFile file) + { + string imageId = Guid.NewGuid().ToString(); + BlobClient blobClient = BlobContainerClient.GetBlobClient(imageId); + BlobHttpHeaders headers = new BlobHttpHeaders { - blockBlob.UploadFromStream(fileStream); - } + ContentType = file.ContentType + }; + await blobClient.UploadAsync(file.OpenReadStream(), headers); + return imageId; } - public Task DownloadImage(string id) + public async Task DownloadImage(string id) { - throw new NotImplementedException(); + BlobClient blobClient = BlobContainerClient.GetBlobClient(id); + bool blobExists = await blobClient.ExistsAsync(); + if (!blobExists) + { + return null; + } + BlobProperties properties = await blobClient.GetPropertiesAsync(); + string contentType = properties.ContentType; + + MemoryStream stream = new MemoryStream(); + await blobClient.DownloadToAsync(stream); + byte[] blobContent = stream.ToArray(); + + return new FileContentResult(blobContent, contentType); } } \ No newline at end of file diff --git a/ChatService.Web/Storage/IImageStore.cs b/ChatService.Web/Storage/IImageStore.cs index 8d28e97..63115bf 100644 --- a/ChatService.Web/Storage/IImageStore.cs +++ b/ChatService.Web/Storage/IImageStore.cs @@ -1,10 +1,10 @@ using ChatService.Web.Dtos; +using Microsoft.AspNetCore.Mvc; namespace ChatService.Web.Storage; public interface IImageStore { - Task UploadImage(UploadImageRequest request); - Task DownloadImage(string id); - + Task UploadImage(IFormFile file); + Task DownloadImage(string id); } \ No newline at end of file diff --git a/ChatService.Web/appsettings.json b/ChatService.Web/appsettings.json index 5b8e899..51777ea 100644 --- a/ChatService.Web/appsettings.json +++ b/ChatService.Web/appsettings.json @@ -11,8 +11,7 @@ }, "Blob": { - "ContainerUri": "https://naa137.blob.core.windows.net/images", - "ConnectionString": "" + "ConnectionString": "DefaultEndpointsProtocol=https;AccountName=naa137;AccountKey=p6VJPQjkAPm3/JxBIY1l7Yj9ewb4Fp2l+Aciwm969OgT57+70hT/jMKcyDIdIwIJaEijfqsxAOdf+AStE/MZDw==;EndpointSuffix=core.windows.net" }, "AllowedHosts": "*" diff --git a/ChatService.sln.DotSettings.user b/ChatService.sln.DotSettings.user index c963ed4..b6e1970 100644 --- a/ChatService.sln.DotSettings.user +++ b/ChatService.sln.DotSettings.user @@ -7,5 +7,12 @@ <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ProfileControllerTests.PostProfile_UsernameTaken</TestId> <TestId>xUnit::A7808CB8-B561-4939-9B4E-8AC0FD786DC3::net6.0::ChatService.Web.IntegrationTests.CosmosProfileStoreTest</TestId> <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ProfileControllerTests</TestId> + <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ImageControllerTests.UploadImage_Success</TestId> + </TestAncestor> +</SessionState> + <SessionState ContinuousTestingMode="0" Name="AddNewProfile_InvalidArgs" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <TestAncestor> + <TestId>xUnit::A7808CB8-B561-4939-9B4E-8AC0FD786DC3::net6.0::ChatService.Web.IntegrationTests.CosmosProfileStoreTest.AddNewProfile_InvalidArgs</TestId> + <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ImageControllerTests.UploadImage_Success</TestId> </TestAncestor> </SessionState> \ No newline at end of file From 7a90a8a1d9b591c451e6a928ae47bb20680940af Mon Sep 17 00:00:00 2001 From: Ali Date: Mon, 20 Feb 2023 17:35:17 +0200 Subject: [PATCH 04/45] Implement ImageControllerTests & Perform Minor Changes --- .../CosmosProfileStoreTests.cs | 1 - .../Controllers/ImageControllerTests.cs | 85 ++++++++++++++++--- .../Controllers/ProfileControllerTests.cs | 2 +- .../Controllers/ImageController.cs | 6 +- .../Controllers/ProfileController.cs | 4 +- ChatService.Web/Storage/CosmosProfileStore.cs | 1 - ChatService.Web/Storage/IImageStore.cs | 1 - ChatService.sln.DotSettings.user | 19 ++++- 8 files changed, 94 insertions(+), 25 deletions(-) diff --git a/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs b/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs index a5de51c..651fb0a 100644 --- a/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs +++ b/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs @@ -5,7 +5,6 @@ namespace ChatService.Web.IntegrationTests; - public class CosmosProfileStoreTest : IClassFixture>, IAsyncLifetime { private readonly IProfileStore _store; diff --git a/ChatService.Web.Tests/Controllers/ImageControllerTests.cs b/ChatService.Web.Tests/Controllers/ImageControllerTests.cs index 9b84bf4..b9fb07a 100644 --- a/ChatService.Web.Tests/Controllers/ImageControllerTests.cs +++ b/ChatService.Web.Tests/Controllers/ImageControllerTests.cs @@ -1,7 +1,10 @@ using System.Net; +using System.Net.Http.Headers; +using System.Text; using ChatService.Web.Dtos; using ChatService.Web.Storage; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; @@ -13,9 +16,9 @@ namespace ChatService.Web.Tests.Controllers; public class ImageControllerTests : IClassFixture> { private readonly Mock _imageStoreMock = new(); - private readonly Mock _formFileMock = new(); private readonly HttpClient _httpClient; - + private readonly MultipartFormDataContent _content = new(); + public ImageControllerTests(WebApplicationFactory factory) { _httpClient = factory.WithWebHostBuilder(builder => @@ -29,25 +32,79 @@ public async Task UploadImage_Success() { var imageId = Guid.NewGuid().ToString(); var uploadImageResponse = new UploadImageResponse(imageId); - - _formFileMock.Setup(m => m.ContentType) - .Returns("image/jpg"); - _formFileMock.Setup(m => m.OpenReadStream()) - .Returns(new MemoryStream()); - _imageStoreMock.Setup(m => m.UploadImage(_formFileMock.Object)) + + _imageStoreMock.Setup(m => m.UploadImage(It.IsAny())) .ReturnsAsync(imageId); - - var content = new MultipartFormDataContent(); - var fileContent = new StreamContent(_formFileMock.Object.OpenReadStream()); - content.Add(fileContent,"File"); - var response = await _httpClient.PostAsync("/Image", content); + var fileContent = new StreamContent(new MemoryStream(Encoding.UTF8.GetBytes("This is a mock image file content"))); + fileContent.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg"); + _content.Add(fileContent,"File", "image.jpeg"); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var response = await _httpClient.PostAsync("/Image", _content); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.Equal($"http://localhost/Image/{imageId}", response.Headers.GetValues("Location").First()); + var json = await response.Content.ReadAsStringAsync(); var receivedUploadImageResponse = JsonConvert.DeserializeObject(json); Assert.Equal(uploadImageResponse, receivedUploadImageResponse); } + [Fact] + public async Task UploadImage_MissingFile() + { + var response = await _httpClient.PostAsync("/Image", _content); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + _imageStoreMock.Verify( m => m.UploadImage(It.IsAny()), Times.Never); + } + + [Fact] + public async Task UploadImage_InvalidFile() + { + var fileContent = new StreamContent(new MemoryStream(Encoding.UTF8.GetBytes("This is a mock text file content"))); + fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain"); + _content.Add(fileContent,"File", "file.txt"); + + var response = await _httpClient.PostAsync("/Image", _content); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var json = await response.Content.ReadAsStringAsync(); + Assert.Equal("Invalid file, must be an image.", json); + + _imageStoreMock.Verify( m => m.UploadImage(It.IsAny()), Times.Never); + } + + [Fact] + public async Task DownloadImage_Success() + { + var imageId = Guid.NewGuid().ToString(); + var fileContentResult = new FileContentResult(new byte[1024], "image/jpg"); + + _imageStoreMock.Setup(m => m.DownloadImage(imageId)) + .ReturnsAsync(fileContentResult); + + var response = await _httpClient.GetAsync($"/Image/{imageId}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsByteArrayAsync(); + var contentType = response.Content.Headers.ContentType.ToString(); + + Assert.Equal(fileContentResult.FileContents, content); + Assert.Equal(fileContentResult.ContentType, contentType); + } + + [Fact] + public async Task DownloadImage_NotFound() + { + var imageId = Guid.NewGuid().ToString(); + + var response = await _httpClient.GetAsync($"/Image/{imageId}"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } } \ No newline at end of file diff --git a/ChatService.Web.Tests/Controllers/ProfileControllerTests.cs b/ChatService.Web.Tests/Controllers/ProfileControllerTests.cs index 7121ea5..5194e6f 100644 --- a/ChatService.Web.Tests/Controllers/ProfileControllerTests.cs +++ b/ChatService.Web.Tests/Controllers/ProfileControllerTests.cs @@ -103,7 +103,7 @@ public async Task PostProfile_InvalidArguments(string username, string firstName { Profile profile = new(username, firstName, lastName, profilePictureId); - var response = await _httpClient.PutAsJsonAsync("/Profile", profile); + var response = await _httpClient.PostAsJsonAsync("/Profile", profile); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); _profileStoreMock.Verify(mock => mock.AddProfile(_profile), Times.Never); diff --git a/ChatService.Web/Controllers/ImageController.cs b/ChatService.Web/Controllers/ImageController.cs index c22b3de..429f05c 100644 --- a/ChatService.Web/Controllers/ImageController.cs +++ b/ChatService.Web/Controllers/ImageController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; namespace ChatService.Web.Controllers; + [ApiController] [Route("[controller]")] public class ImageController : ControllerBase @@ -17,7 +18,6 @@ public ImageController(IImageStore imageStore) [HttpPost] public async Task> UploadImage([FromForm] UploadImageRequest request) { - string contentType = request.File.ContentType.ToLower(); if (contentType != "image/jpg" && contentType != "image/jpeg" && @@ -27,10 +27,10 @@ public async Task> UploadImage([FromForm] Uplo } string imageId = await _imageStore.UploadImage(request.File); - return Ok(new UploadImageResponse(imageId)); + return CreatedAtAction(nameof(DownloadImage), new { id = imageId }, new UploadImageResponse(imageId)); } - [HttpGet("{id}")] + [HttpGet("{id}")] public async Task DownloadImage(string id) { FileContentResult? imageResult = await _imageStore.DownloadImage(id); diff --git a/ChatService.Web/Controllers/ProfileController.cs b/ChatService.Web/Controllers/ProfileController.cs index 571d53d..f801b91 100644 --- a/ChatService.Web/Controllers/ProfileController.cs +++ b/ChatService.Web/Controllers/ProfileController.cs @@ -14,12 +14,10 @@ public ProfileController(IProfileStore profileStore) { _profileStore = profileStore; } - - + [HttpGet("{username}")] public async Task> GetProfile(string username) { - var profile = await _profileStore.GetProfile(username); if (profile == null) { diff --git a/ChatService.Web/Storage/CosmosProfileStore.cs b/ChatService.Web/Storage/CosmosProfileStore.cs index de21ea3..ebde0b1 100644 --- a/ChatService.Web/Storage/CosmosProfileStore.cs +++ b/ChatService.Web/Storage/CosmosProfileStore.cs @@ -74,7 +74,6 @@ await Container.DeleteItemAsync( } } - private static ProfileEntity ToEntity(Profile profile) { return new ProfileEntity( diff --git a/ChatService.Web/Storage/IImageStore.cs b/ChatService.Web/Storage/IImageStore.cs index 63115bf..98e58cc 100644 --- a/ChatService.Web/Storage/IImageStore.cs +++ b/ChatService.Web/Storage/IImageStore.cs @@ -1,4 +1,3 @@ -using ChatService.Web.Dtos; using Microsoft.AspNetCore.Mvc; namespace ChatService.Web.Storage; diff --git a/ChatService.sln.DotSettings.user b/ChatService.sln.DotSettings.user index b6e1970..39d77ee 100644 --- a/ChatService.sln.DotSettings.user +++ b/ChatService.sln.DotSettings.user @@ -1,5 +1,13 @@  - <SessionState ContinuousTestingMode="0" IsActive="True" Name="GetProfile_Success" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + On + On + On + <SessionState ContinuousTestingMode="0" Name="UploadImage_Success" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <TestAncestor> + <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ImageControllerTests.UploadImage_Success</TestId> + </TestAncestor> +</SessionState> + <SessionState ContinuousTestingMode="0" Name="GetProfile_Success" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <TestAncestor> <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ProfileControllerTests.GetProfile_Success</TestId> <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ProfileControllerTests.GetProfile_ProfileNotFound</TestId> @@ -9,10 +17,19 @@ <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ProfileControllerTests</TestId> <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ImageControllerTests.UploadImage_Success</TestId> </TestAncestor> +</SessionState> + <SessionState ContinuousTestingMode="0" Name="UploadImage_Success #2" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <TestAncestor> + <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ImageControllerTests.UploadImage_Success</TestId> + </TestAncestor> +</SessionState> + <SessionState ContinuousTestingMode="0" IsActive="True" Name="UploadImage_Success #3" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Solution /> </SessionState> <SessionState ContinuousTestingMode="0" Name="AddNewProfile_InvalidArgs" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <TestAncestor> <TestId>xUnit::A7808CB8-B561-4939-9B4E-8AC0FD786DC3::net6.0::ChatService.Web.IntegrationTests.CosmosProfileStoreTest.AddNewProfile_InvalidArgs</TestId> <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ImageControllerTests.UploadImage_Success</TestId> + <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ImageControllerTests</TestId> </TestAncestor> </SessionState> \ No newline at end of file From a68c064b3a35d9177713c24f31a49f9650350f7a Mon Sep 17 00:00:00 2001 From: Ali Date: Tue, 21 Feb 2023 19:45:10 +0200 Subject: [PATCH 05/45] Create ImageDto & Modify ImageController and BlobImageStore accordingly --- .../BlobImageStoreTests.cs | 41 +++++++++++++++++++ .../Controllers/ImageControllerTests.cs | 17 ++++---- .../Controllers/ImageController.cs | 12 ++++-- ChatService.Web/Dtos/ImageDto.cs | 5 +++ ChatService.Web/Storage/BlobImageStore.cs | 26 +++++++----- ChatService.Web/Storage/IImageStore.cs | 7 ++-- ChatService.sln.DotSettings.user | 2 + 7 files changed, 85 insertions(+), 25 deletions(-) create mode 100644 ChatService.Web.IntegrationTests/BlobImageStoreTests.cs create mode 100644 ChatService.Web/Dtos/ImageDto.cs diff --git a/ChatService.Web.IntegrationTests/BlobImageStoreTests.cs b/ChatService.Web.IntegrationTests/BlobImageStoreTests.cs new file mode 100644 index 0000000..cb0849a --- /dev/null +++ b/ChatService.Web.IntegrationTests/BlobImageStoreTests.cs @@ -0,0 +1,41 @@ +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 BlobImageStoreTests : IClassFixture>, IAsyncLifetime +{ + private readonly IImageStore _store; + private string _imageId; + + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + await _store.DeleteImage(_imageId); + } + + public BlobImageStoreTests(WebApplicationFactory factory) + { + _store = factory.Services.GetRequiredService(); + } + + [Fact] + public async Task UploadImage_Success() + { + var image = new ImageDto("image/jpg", new MemoryStream(Encoding.UTF8.GetBytes("This is a mock image file content"))); + string imageId = await _store.UploadImage(image); + var receivedImage = await _store.DownloadImage(imageId); + + Assert.Equal(image.ContentType, receivedImage.ContentType); + Assert.True(image.Content.ToArray().SequenceEqual(receivedImage.Content.ToArray())); + + _imageId = imageId; + } +} \ No newline at end of file diff --git a/ChatService.Web.Tests/Controllers/ImageControllerTests.cs b/ChatService.Web.Tests/Controllers/ImageControllerTests.cs index b9fb07a..9936106 100644 --- a/ChatService.Web.Tests/Controllers/ImageControllerTests.cs +++ b/ChatService.Web.Tests/Controllers/ImageControllerTests.cs @@ -3,7 +3,6 @@ using System.Text; using ChatService.Web.Dtos; using ChatService.Web.Storage; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; @@ -30,14 +29,15 @@ public ImageControllerTests(WebApplicationFactory factory) [Fact] public async Task UploadImage_Success() { + var image = new ImageDto("image/jpeg", new MemoryStream()); var imageId = Guid.NewGuid().ToString(); var uploadImageResponse = new UploadImageResponse(imageId); - _imageStoreMock.Setup(m => m.UploadImage(It.IsAny())) + _imageStoreMock.Setup(m => m.UploadImage(It.IsAny())) .ReturnsAsync(imageId); - var fileContent = new StreamContent(new MemoryStream(Encoding.UTF8.GetBytes("This is a mock image file content"))); - fileContent.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg"); + var fileContent = new StreamContent(image.Content); + fileContent.Headers.ContentType = new MediaTypeHeaderValue(image.ContentType); _content.Add(fileContent,"File", "image.jpeg"); var response = await _httpClient.PostAsync("/Image", _content); @@ -58,7 +58,7 @@ public async Task UploadImage_MissingFile() Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - _imageStoreMock.Verify( m => m.UploadImage(It.IsAny()), Times.Never); + _imageStoreMock.Verify( m => m.UploadImage(It.IsAny()), Times.Never); } [Fact] @@ -75,17 +75,18 @@ public async Task UploadImage_InvalidFile() var json = await response.Content.ReadAsStringAsync(); Assert.Equal("Invalid file, must be an image.", json); - _imageStoreMock.Verify( m => m.UploadImage(It.IsAny()), Times.Never); + _imageStoreMock.Verify( m => m.UploadImage(It.IsAny()), Times.Never); } [Fact] public async Task DownloadImage_Success() { var imageId = Guid.NewGuid().ToString(); - var fileContentResult = new FileContentResult(new byte[1024], "image/jpg"); + var image = new ImageDto("image/jpeg", new MemoryStream()); + var fileContentResult = new FileContentResult(image.Content.ToArray(), image.ContentType); _imageStoreMock.Setup(m => m.DownloadImage(imageId)) - .ReturnsAsync(fileContentResult); + .ReturnsAsync(image); var response = await _httpClient.GetAsync($"/Image/{imageId}"); diff --git a/ChatService.Web/Controllers/ImageController.cs b/ChatService.Web/Controllers/ImageController.cs index 429f05c..770650b 100644 --- a/ChatService.Web/Controllers/ImageController.cs +++ b/ChatService.Web/Controllers/ImageController.cs @@ -25,19 +25,23 @@ public async Task> UploadImage([FromForm] Uplo { return BadRequest($"Invalid file, must be an image."); } + + MemoryStream content = new(); + await request.File.CopyToAsync(content); + ImageDto image = new ImageDto(contentType, content); - string imageId = await _imageStore.UploadImage(request.File); + string imageId = await _imageStore.UploadImage(image); return CreatedAtAction(nameof(DownloadImage), new { id = imageId }, new UploadImageResponse(imageId)); } [HttpGet("{id}")] public async Task DownloadImage(string id) { - FileContentResult? imageResult = await _imageStore.DownloadImage(id); - if (imageResult == null) + ImageDto? image = await _imageStore.DownloadImage(id); + if (image == null) { return NotFound($"An image with id {id} was not found."); } - return imageResult; + return new FileContentResult(image.Content.ToArray(), image.ContentType); } } \ No newline at end of file diff --git a/ChatService.Web/Dtos/ImageDto.cs b/ChatService.Web/Dtos/ImageDto.cs new file mode 100644 index 0000000..4aba56f --- /dev/null +++ b/ChatService.Web/Dtos/ImageDto.cs @@ -0,0 +1,5 @@ +namespace ChatService.Web.Dtos; + +public record ImageDto( + string ContentType, + MemoryStream Content); \ No newline at end of file diff --git a/ChatService.Web/Storage/BlobImageStore.cs b/ChatService.Web/Storage/BlobImageStore.cs index ed178be..2ffe1ef 100644 --- a/ChatService.Web/Storage/BlobImageStore.cs +++ b/ChatService.Web/Storage/BlobImageStore.cs @@ -1,6 +1,6 @@ using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; -using Microsoft.AspNetCore.Mvc; +using ChatService.Web.Dtos; namespace ChatService.Web.Storage; @@ -15,19 +15,20 @@ public BlobImageStore(BlobServiceClient blobServiceClient) private BlobContainerClient BlobContainerClient => _blobServiceClient.GetBlobContainerClient("images"); - public async Task UploadImage(IFormFile file) + public async Task UploadImage(ImageDto image) { string imageId = Guid.NewGuid().ToString(); BlobClient blobClient = BlobContainerClient.GetBlobClient(imageId); BlobHttpHeaders headers = new BlobHttpHeaders { - ContentType = file.ContentType + ContentType = image.ContentType }; - await blobClient.UploadAsync(file.OpenReadStream(), headers); + image.Content.Position = 0; + await blobClient.UploadAsync(image.Content, headers); return imageId; } - public async Task DownloadImage(string id) + public async Task DownloadImage(string id) { BlobClient blobClient = BlobContainerClient.GetBlobClient(id); bool blobExists = await blobClient.ExistsAsync(); @@ -38,10 +39,15 @@ public async Task UploadImage(IFormFile file) BlobProperties properties = await blobClient.GetPropertiesAsync(); string contentType = properties.ContentType; - MemoryStream stream = new MemoryStream(); - await blobClient.DownloadToAsync(stream); - byte[] blobContent = stream.ToArray(); - - return new FileContentResult(blobContent, contentType); + MemoryStream content = new MemoryStream(); + await blobClient.DownloadToAsync(content); + + return new ImageDto(contentType, content); + } + + public async Task DeleteImage(string id) + { + BlobClient blobClient = BlobContainerClient.GetBlobClient(id); + await blobClient.DeleteIfExistsAsync(); } } \ No newline at end of file diff --git a/ChatService.Web/Storage/IImageStore.cs b/ChatService.Web/Storage/IImageStore.cs index 98e58cc..d28ceec 100644 --- a/ChatService.Web/Storage/IImageStore.cs +++ b/ChatService.Web/Storage/IImageStore.cs @@ -1,9 +1,10 @@ -using Microsoft.AspNetCore.Mvc; +using ChatService.Web.Dtos; namespace ChatService.Web.Storage; public interface IImageStore { - Task UploadImage(IFormFile file); - Task DownloadImage(string id); + Task UploadImage(ImageDto image); + Task DownloadImage(string id); + Task DeleteImage(string id); } \ No newline at end of file diff --git a/ChatService.sln.DotSettings.user b/ChatService.sln.DotSettings.user index 39d77ee..4ffbb52 100644 --- a/ChatService.sln.DotSettings.user +++ b/ChatService.sln.DotSettings.user @@ -1,7 +1,9 @@  + INFO On On On + C:\Users\HP\AppData\Local\JetBrains\Rider2022.3\resharper-host\temp\Rider\vAny\CoverageData\_ChatService.-1400260303\Snapshot\snapshot.utdcvr <SessionState ContinuousTestingMode="0" Name="UploadImage_Success" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <TestAncestor> <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ImageControllerTests.UploadImage_Success</TestId> From f4565883b5026cab9c895e23fdb29e3cfd66b25a Mon Sep 17 00:00:00 2001 From: nadimakk Date: Tue, 21 Feb 2023 22:02:04 +0200 Subject: [PATCH 06/45] add checks for validity of profile pic, add delete image when profile deleted, completed tests --- .../BlobImageStoreTests.cs | 51 +++++++++++++++++-- .../ChatService.Web.IntegrationTests.csproj | 1 + .../CosmosProfileStoreTests.cs | 38 ++++++++++---- .../Controllers/ProfileControllerTests.cs | 24 ++++++++- .../Controllers/ProfileController.cs | 10 +++- ChatService.Web/Storage/BlobImageStore.cs | 19 ++++++- ChatService.Web/Storage/CosmosProfileStore.cs | 36 +++++++------ ChatService.Web/Storage/IImageStore.cs | 3 +- ChatService.sln.DotSettings.user | 6 +-- 9 files changed, 149 insertions(+), 39 deletions(-) diff --git a/ChatService.Web.IntegrationTests/BlobImageStoreTests.cs b/ChatService.Web.IntegrationTests/BlobImageStoreTests.cs index cb0849a..f494219 100644 --- a/ChatService.Web.IntegrationTests/BlobImageStoreTests.cs +++ b/ChatService.Web.IntegrationTests/BlobImageStoreTests.cs @@ -9,6 +9,8 @@ namespace ChatService.Web.IntegrationTests; public class BlobImageStoreTests : IClassFixture>, IAsyncLifetime { private readonly IImageStore _store; + private readonly ImageDto _image = new ImageDto("image/jpg", + new MemoryStream(Encoding.UTF8.GetBytes("This is a mock image file content"))); private string _imageId; public Task InitializeAsync() @@ -29,13 +31,52 @@ public BlobImageStoreTests(WebApplicationFactory factory) [Fact] public async Task UploadImage_Success() { - var image = new ImageDto("image/jpg", new MemoryStream(Encoding.UTF8.GetBytes("This is a mock image file content"))); - string imageId = await _store.UploadImage(image); - var receivedImage = await _store.DownloadImage(imageId); + string imageId = await _store.UploadImage(_image); + var downloadedImage = await _store.DownloadImage(imageId); - Assert.Equal(image.ContentType, receivedImage.ContentType); - Assert.True(image.Content.ToArray().SequenceEqual(receivedImage.Content.ToArray())); + Assert.Equal(_image.ContentType, downloadedImage.ContentType); + Assert.True(_image.Content.ToArray().SequenceEqual(downloadedImage.Content.ToArray())); _imageId = imageId; } + + [Fact] + public async Task UploadImage_Failure() + { + var notImage = new ImageDto("text/plain", + new MemoryStream(Encoding.UTF8.GetBytes("This is a mock file simulating an invalid image type"))); + + await Assert.ThrowsAsync(async () => await _store.UploadImage(notImage)); + } + + [Fact] + public async Task DownloadImage_Success() + { + var imageId = await _store.UploadImage(_image); + var downloadedImage = await _store.DownloadImage(imageId); + + Assert.Equal(_image.ContentType, downloadedImage.ContentType); + Assert.True(_image.Content.ToArray().SequenceEqual(downloadedImage.Content.ToArray())); + } + + [Fact] + public async Task DownloadImage_NotFound() + { + var downloadedImage = await _store.DownloadImage("dummy_id"); + + Assert.Null(downloadedImage); + } + + [Fact] + public async Task DeleteImage_Success() + { + var imageId = await _store.UploadImage(_image); + Assert.True(await _store.DeleteImage(imageId)); + } + + [Fact] + public async Task DeleteImage_Failure() + { + Assert.False(await _store.DeleteImage("dummy_id")); + } } \ No newline at end of file diff --git a/ChatService.Web.IntegrationTests/ChatService.Web.IntegrationTests.csproj b/ChatService.Web.IntegrationTests/ChatService.Web.IntegrationTests.csproj index de692c2..6b8e912 100644 --- a/ChatService.Web.IntegrationTests/ChatService.Web.IntegrationTests.csproj +++ b/ChatService.Web.IntegrationTests/ChatService.Web.IntegrationTests.csproj @@ -12,6 +12,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs b/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs index 651fb0a..f708dfa 100644 --- a/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs +++ b/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs @@ -2,18 +2,20 @@ using ChatService.Web.Storage; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; +using Moq; namespace ChatService.Web.IntegrationTests; public class CosmosProfileStoreTest : IClassFixture>, IAsyncLifetime { private readonly IProfileStore _store; + private readonly Mock _imageStoreMock = new(); private readonly Profile _profile = new( username: Guid.NewGuid().ToString(), firstName: "Foo", lastName: "Bar", - profilePictureId: "123" + profilePictureId: "dummy_id" ); public Task InitializeAsync() @@ -29,25 +31,29 @@ public async Task DisposeAsync() public CosmosProfileStoreTest(WebApplicationFactory factory) { _store = factory.Services.GetRequiredService(); + //TODO:inject mock } [Fact] public async Task AddNewProfile_Success() { + _imageStoreMock.Setup(m => m.ImageExists(_profile.profilePictureId)) + .ReturnsAsync(true); + await _store.AddProfile(_profile); Assert.Equal(_profile, await _store.GetProfile(_profile.username)); } [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(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"," ")] @@ -62,6 +68,15 @@ public async Task AddNewProfile_NullProfile() { await Assert.ThrowsAsync( async () => await _store.AddProfile(null)); } + + [Fact] + public async Task AddNewProfile_ProfilePictureNotFound() + { + _imageStoreMock.Setup(m => m.ImageExists(_profile.profilePictureId)) + .ReturnsAsync(false); + + await Assert.ThrowsAsync( async () => await _store.AddProfile(_profile)); + } [Fact] public async Task GetNonExistingProfile() @@ -72,6 +87,9 @@ public async Task GetNonExistingProfile() [Fact] public async Task DeleteProfile() { + _imageStoreMock.Setup(m => m.ImageExists(_profile.profilePictureId)) + .ReturnsAsync(true); + await _store.AddProfile(_profile); Assert.Equal(_profile, await _store.GetProfile(_profile.username)); await _store.DeleteProfile(_profile.username); diff --git a/ChatService.Web.Tests/Controllers/ProfileControllerTests.cs b/ChatService.Web.Tests/Controllers/ProfileControllerTests.cs index 5194e6f..5444609 100644 --- a/ChatService.Web.Tests/Controllers/ProfileControllerTests.cs +++ b/ChatService.Web.Tests/Controllers/ProfileControllerTests.cs @@ -13,6 +13,7 @@ namespace ChatService.Web.Tests.Controllers; public class ProfileControllerTests : IClassFixture> { private readonly Mock _profileStoreMock = new(); + private readonly Mock _imageStoreMock = new(); private readonly HttpClient _httpClient; private readonly Profile _profile = new Profile("foobar", "Foo", "Bar", "123"); @@ -20,7 +21,11 @@ public ProfileControllerTests(WebApplicationFactory factory) { _httpClient = factory.WithWebHostBuilder(builder => { - builder.ConfigureTestServices(services => { services.AddSingleton(_profileStoreMock.Object); }); + builder.ConfigureTestServices(services => + { + services.AddSingleton(_profileStoreMock.Object); + services.AddSingleton(_imageStoreMock.Object); + }); }).CreateClient(); } @@ -58,6 +63,8 @@ public async Task PostProfile_Success() { _profileStoreMock.Setup(m => m.GetProfile(_profile.username)) .ReturnsAsync((Profile?) null); + _imageStoreMock.Setup(m => m.ImageExists(_profile.profilePictureId)) + .ReturnsAsync(true); var response = await _httpClient.PostAsJsonAsync("/Profile/", _profile); @@ -108,4 +115,19 @@ public async Task PostProfile_InvalidArguments(string username, string firstName Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); _profileStoreMock.Verify(mock => mock.AddProfile(_profile), Times.Never); } + + [Fact] + public async Task ProfileProfile_ProfilePictureNotFound() + { + _profileStoreMock.Setup(m => m.GetProfile(_profile.username)) + .ReturnsAsync((Profile?) null); + _imageStoreMock.Setup(m => m.ImageExists(_profile.profilePictureId)) + .ReturnsAsync(false); + + var response = await _httpClient.PostAsJsonAsync("/Profile/", _profile); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + _profileStoreMock.Verify(mock => mock.AddProfile(_profile), Times.Never); + } } \ No newline at end of file diff --git a/ChatService.Web/Controllers/ProfileController.cs b/ChatService.Web/Controllers/ProfileController.cs index f801b91..f2206fd 100644 --- a/ChatService.Web/Controllers/ProfileController.cs +++ b/ChatService.Web/Controllers/ProfileController.cs @@ -9,10 +9,12 @@ namespace ChatService.Web.Controllers; public class ProfileController : ControllerBase { private readonly IProfileStore _profileStore; + private readonly IImageStore _imageStore; - public ProfileController(IProfileStore profileStore) + public ProfileController(IProfileStore profileStore, IImageStore imageStore) { _profileStore = profileStore; + _imageStore = imageStore; } [HttpGet("{username}")] @@ -36,6 +38,12 @@ public async Task> PostProfile(Profile profile) return Conflict($"A user with the username {profile.username} already exist."); } + bool imageExists = await _imageStore.ImageExists(profile.profilePictureId); + if (!imageExists) + { + return BadRequest("The profile picture of the profile does not exist."); + } + await _profileStore.AddProfile(profile); return CreatedAtAction(nameof(GetProfile), new { username = profile.username }, profile); } diff --git a/ChatService.Web/Storage/BlobImageStore.cs b/ChatService.Web/Storage/BlobImageStore.cs index 2ffe1ef..0c6c6ca 100644 --- a/ChatService.Web/Storage/BlobImageStore.cs +++ b/ChatService.Web/Storage/BlobImageStore.cs @@ -17,6 +17,15 @@ public BlobImageStore(BlobServiceClient blobServiceClient) public async Task UploadImage(ImageDto 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."); + } + string imageId = Guid.NewGuid().ToString(); BlobClient blobClient = BlobContainerClient.GetBlobClient(imageId); BlobHttpHeaders headers = new BlobHttpHeaders @@ -45,9 +54,15 @@ public async Task UploadImage(ImageDto image) return new ImageDto(contentType, content); } - public async Task DeleteImage(string id) + 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); - await blobClient.DeleteIfExistsAsync(); + return await blobClient.ExistsAsync(); } } \ No newline at end of file diff --git a/ChatService.Web/Storage/CosmosProfileStore.cs b/ChatService.Web/Storage/CosmosProfileStore.cs index ebde0b1..ced9603 100644 --- a/ChatService.Web/Storage/CosmosProfileStore.cs +++ b/ChatService.Web/Storage/CosmosProfileStore.cs @@ -8,10 +8,12 @@ namespace ChatService.Web.Storage; public class CosmosProfileStore : IProfileStore { private readonly CosmosClient _cosmosClient; - - public CosmosProfileStore(CosmosClient cosmosClient) + private readonly IImageStore _imageStore; + + public CosmosProfileStore(CosmosClient cosmosClient, IImageStore imageStore) { _cosmosClient = cosmosClient; + _imageStore = imageStore; } private Container Container => _cosmosClient.GetDatabase("chatService").GetContainer("profiles"); @@ -28,7 +30,14 @@ public async Task AddProfile(Profile profile) throw new ArgumentException($"Invalid profile {profile}", nameof(profile)); } + bool imageExists = await _imageStore.ImageExists(profile.profilePictureId); + if (!imageExists) + { + throw new ArgumentException("The profile picture of the profile does not exist."); + } + await Container.UpsertItemAsync(ToEntity(profile)); + } public async Task GetProfile(string username) @@ -57,21 +66,18 @@ public async Task AddProfile(Profile profile) public async Task DeleteProfile(string username) { - try - { - await Container.DeleteItemAsync( - id: username, - partitionKey: new PartitionKey(username) - ); - } - catch (CosmosException e) + Profile? profile = await GetProfile(username); + + if (profile == null) { - if (e.StatusCode == HttpStatusCode.NotFound) - { - return; - } - throw; + return; } + + await _imageStore.DeleteImage(profile.profilePictureId); + + await Container.DeleteItemAsync( + id: username, + partitionKey: new PartitionKey(username)); } private static ProfileEntity ToEntity(Profile profile) diff --git a/ChatService.Web/Storage/IImageStore.cs b/ChatService.Web/Storage/IImageStore.cs index d28ceec..514fe75 100644 --- a/ChatService.Web/Storage/IImageStore.cs +++ b/ChatService.Web/Storage/IImageStore.cs @@ -6,5 +6,6 @@ public interface IImageStore { Task UploadImage(ImageDto image); Task DownloadImage(string id); - Task DeleteImage(string id); + Task DeleteImage(string id); + Task ImageExists(string id); } \ No newline at end of file diff --git a/ChatService.sln.DotSettings.user b/ChatService.sln.DotSettings.user index 4ffbb52..e1dbe6c 100644 --- a/ChatService.sln.DotSettings.user +++ b/ChatService.sln.DotSettings.user @@ -3,7 +3,7 @@ On On On - C:\Users\HP\AppData\Local\JetBrains\Rider2022.3\resharper-host\temp\Rider\vAny\CoverageData\_ChatService.-1400260303\Snapshot\snapshot.utdcvr + <SessionState ContinuousTestingMode="0" Name="UploadImage_Success" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <TestAncestor> <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ImageControllerTests.UploadImage_Success</TestId> @@ -21,9 +21,7 @@ </TestAncestor> </SessionState> <SessionState ContinuousTestingMode="0" Name="UploadImage_Success #2" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <TestAncestor> - <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ImageControllerTests.UploadImage_Success</TestId> - </TestAncestor> + <Solution /> </SessionState> <SessionState ContinuousTestingMode="0" IsActive="True" Name="UploadImage_Success #3" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> From 2a856150abd3097a1e08cf8d72abc7636b110b94 Mon Sep 17 00:00:00 2001 From: Ali Date: Wed, 22 Feb 2023 17:06:12 +0200 Subject: [PATCH 07/45] Change ImageDto to Image --- .../BlobImageStoreTests.cs | 4 ++-- .../Controllers/ImageControllerTests.cs | 10 +++++----- ChatService.Web/Controllers/ImageController.cs | 4 ++-- ChatService.Web/Dtos/{ImageDto.cs => Image.cs} | 2 +- ChatService.Web/Storage/BlobImageStore.cs | 6 +++--- ChatService.Web/Storage/IImageStore.cs | 4 ++-- 6 files changed, 15 insertions(+), 15 deletions(-) rename ChatService.Web/Dtos/{ImageDto.cs => Image.cs} (77%) diff --git a/ChatService.Web.IntegrationTests/BlobImageStoreTests.cs b/ChatService.Web.IntegrationTests/BlobImageStoreTests.cs index f494219..0f16514 100644 --- a/ChatService.Web.IntegrationTests/BlobImageStoreTests.cs +++ b/ChatService.Web.IntegrationTests/BlobImageStoreTests.cs @@ -9,7 +9,7 @@ namespace ChatService.Web.IntegrationTests; public class BlobImageStoreTests : IClassFixture>, IAsyncLifetime { private readonly IImageStore _store; - private readonly ImageDto _image = new ImageDto("image/jpg", + private readonly Image _image = new Image("image/jpg", new MemoryStream(Encoding.UTF8.GetBytes("This is a mock image file content"))); private string _imageId; @@ -43,7 +43,7 @@ public async Task UploadImage_Success() [Fact] public async Task UploadImage_Failure() { - var notImage = new ImageDto("text/plain", + var notImage = new Image("text/plain", new MemoryStream(Encoding.UTF8.GetBytes("This is a mock file simulating an invalid image type"))); await Assert.ThrowsAsync(async () => await _store.UploadImage(notImage)); diff --git a/ChatService.Web.Tests/Controllers/ImageControllerTests.cs b/ChatService.Web.Tests/Controllers/ImageControllerTests.cs index 9936106..bb9b3e9 100644 --- a/ChatService.Web.Tests/Controllers/ImageControllerTests.cs +++ b/ChatService.Web.Tests/Controllers/ImageControllerTests.cs @@ -29,11 +29,11 @@ public ImageControllerTests(WebApplicationFactory factory) [Fact] public async Task UploadImage_Success() { - var image = new ImageDto("image/jpeg", new MemoryStream()); + var image = new Image("image/jpeg", new MemoryStream()); var imageId = Guid.NewGuid().ToString(); var uploadImageResponse = new UploadImageResponse(imageId); - _imageStoreMock.Setup(m => m.UploadImage(It.IsAny())) + _imageStoreMock.Setup(m => m.UploadImage(It.IsAny())) .ReturnsAsync(imageId); var fileContent = new StreamContent(image.Content); @@ -58,7 +58,7 @@ public async Task UploadImage_MissingFile() Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - _imageStoreMock.Verify( m => m.UploadImage(It.IsAny()), Times.Never); + _imageStoreMock.Verify( m => m.UploadImage(It.IsAny()), Times.Never); } [Fact] @@ -75,14 +75,14 @@ public async Task UploadImage_InvalidFile() var json = await response.Content.ReadAsStringAsync(); Assert.Equal("Invalid file, must be an image.", json); - _imageStoreMock.Verify( m => m.UploadImage(It.IsAny()), Times.Never); + _imageStoreMock.Verify( m => m.UploadImage(It.IsAny()), Times.Never); } [Fact] public async Task DownloadImage_Success() { var imageId = Guid.NewGuid().ToString(); - var image = new ImageDto("image/jpeg", new MemoryStream()); + var image = new Image("image/jpeg", new MemoryStream()); var fileContentResult = new FileContentResult(image.Content.ToArray(), image.ContentType); _imageStoreMock.Setup(m => m.DownloadImage(imageId)) diff --git a/ChatService.Web/Controllers/ImageController.cs b/ChatService.Web/Controllers/ImageController.cs index 770650b..d04039f 100644 --- a/ChatService.Web/Controllers/ImageController.cs +++ b/ChatService.Web/Controllers/ImageController.cs @@ -28,7 +28,7 @@ public async Task> UploadImage([FromForm] Uplo MemoryStream content = new(); await request.File.CopyToAsync(content); - ImageDto image = new ImageDto(contentType, content); + Image image = new Image(contentType, content); string imageId = await _imageStore.UploadImage(image); return CreatedAtAction(nameof(DownloadImage), new { id = imageId }, new UploadImageResponse(imageId)); @@ -37,7 +37,7 @@ public async Task> UploadImage([FromForm] Uplo [HttpGet("{id}")] public async Task DownloadImage(string id) { - ImageDto? image = await _imageStore.DownloadImage(id); + Image? image = await _imageStore.DownloadImage(id); if (image == null) { return NotFound($"An image with id {id} was not found."); diff --git a/ChatService.Web/Dtos/ImageDto.cs b/ChatService.Web/Dtos/Image.cs similarity index 77% rename from ChatService.Web/Dtos/ImageDto.cs rename to ChatService.Web/Dtos/Image.cs index 4aba56f..5ba38a9 100644 --- a/ChatService.Web/Dtos/ImageDto.cs +++ b/ChatService.Web/Dtos/Image.cs @@ -1,5 +1,5 @@ namespace ChatService.Web.Dtos; -public record ImageDto( +public record Image( string ContentType, MemoryStream Content); \ No newline at end of file diff --git a/ChatService.Web/Storage/BlobImageStore.cs b/ChatService.Web/Storage/BlobImageStore.cs index 0c6c6ca..2831eb7 100644 --- a/ChatService.Web/Storage/BlobImageStore.cs +++ b/ChatService.Web/Storage/BlobImageStore.cs @@ -15,7 +15,7 @@ public BlobImageStore(BlobServiceClient blobServiceClient) private BlobContainerClient BlobContainerClient => _blobServiceClient.GetBlobContainerClient("images"); - public async Task UploadImage(ImageDto image) + public async Task UploadImage(Image image) { string contentType = image.ContentType.ToLower(); @@ -37,7 +37,7 @@ public async Task UploadImage(ImageDto image) return imageId; } - public async Task DownloadImage(string id) + public async Task DownloadImage(string id) { BlobClient blobClient = BlobContainerClient.GetBlobClient(id); bool blobExists = await blobClient.ExistsAsync(); @@ -51,7 +51,7 @@ public async Task UploadImage(ImageDto image) MemoryStream content = new MemoryStream(); await blobClient.DownloadToAsync(content); - return new ImageDto(contentType, content); + return new Image(contentType, content); } public async Task DeleteImage(string id) diff --git a/ChatService.Web/Storage/IImageStore.cs b/ChatService.Web/Storage/IImageStore.cs index 514fe75..2d5b31e 100644 --- a/ChatService.Web/Storage/IImageStore.cs +++ b/ChatService.Web/Storage/IImageStore.cs @@ -4,8 +4,8 @@ namespace ChatService.Web.Storage; public interface IImageStore { - Task UploadImage(ImageDto image); - Task DownloadImage(string id); + Task UploadImage(Image image); + Task DownloadImage(string id); Task DeleteImage(string id); Task ImageExists(string id); } \ No newline at end of file From d82da041ae81cf17d1f2d986cb4e4ce45b502f45 Mon Sep 17 00:00:00 2001 From: Ali Date: Wed, 22 Feb 2023 17:09:13 +0200 Subject: [PATCH 08/45] Modify CosmosProfileStoreTests Constructor to handle dependencies injection --- .../CosmosProfileStoreTests.cs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs b/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs index f708dfa..a1af821 100644 --- a/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs +++ b/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs @@ -1,6 +1,8 @@ using ChatService.Web.Dtos; using ChatService.Web.Storage; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Azure.Cosmos; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Moq; @@ -30,8 +32,19 @@ public async Task DisposeAsync() public CosmosProfileStoreTest(WebApplicationFactory factory) { - _store = factory.Services.GetRequiredService(); - //TODO:inject mock + var config = new ConfigurationBuilder() + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .Build(); + + string connectionString = config.GetSection("Cosmos").GetValue("ConnectionString"); + + var services = new ServiceCollection(); + services.AddSingleton(_imageStoreMock.Object); + services.AddSingleton(new CosmosClient(connectionString)); + services.AddSingleton(); + + var serviceProvider = services.BuildServiceProvider(); + _store = serviceProvider.GetRequiredService(); } [Fact] From 569d4b3254c15654f57574207e8050c00d843145 Mon Sep 17 00:00:00 2001 From: nadimakk Date: Wed, 22 Feb 2023 21:20:47 +0200 Subject: [PATCH 09/45] use response from DownloadToAsync to avoid calling Blob storage --- ChatService.Web/Storage/BlobImageStore.cs | 26 ++++++++++++++--------- ChatService.sln.DotSettings.user | 6 ++++++ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/ChatService.Web/Storage/BlobImageStore.cs b/ChatService.Web/Storage/BlobImageStore.cs index 2831eb7..24e4e60 100644 --- a/ChatService.Web/Storage/BlobImageStore.cs +++ b/ChatService.Web/Storage/BlobImageStore.cs @@ -1,3 +1,4 @@ +using Azure; using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; using ChatService.Web.Dtos; @@ -40,18 +41,23 @@ public async Task UploadImage(Image image) public async Task DownloadImage(string id) { BlobClient blobClient = BlobContainerClient.GetBlobClient(id); - bool blobExists = await blobClient.ExistsAsync(); - if (!blobExists) + + try { - return null; + MemoryStream content = new MemoryStream(); + await blobClient.DownloadToAsync(content); + BlobProperties properties = await blobClient.GetPropertiesAsync(); + string contentType = properties.ContentType; + return new Image(contentType, content); + } + catch (RequestFailedException ex) + { + if (ex.Status == 404) + { + return null; + } + throw; } - BlobProperties properties = await blobClient.GetPropertiesAsync(); - string contentType = properties.ContentType; - - MemoryStream content = new MemoryStream(); - await blobClient.DownloadToAsync(content); - - return new Image(contentType, content); } public async Task DeleteImage(string id) diff --git a/ChatService.sln.DotSettings.user b/ChatService.sln.DotSettings.user index e1dbe6c..3d8a1fa 100644 --- a/ChatService.sln.DotSettings.user +++ b/ChatService.sln.DotSettings.user @@ -3,11 +3,17 @@ On On On + C:\Users\nadim\AppData\Local\JetBrains\Rider2022.3\resharper-host\temp\Rider\vAny\CoverageData\_ChatService.34854384\Snapshot\snapshot.utdcvr <SessionState ContinuousTestingMode="0" Name="UploadImage_Success" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <TestAncestor> <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ImageControllerTests.UploadImage_Success</TestId> </TestAncestor> +</SessionState> + <SessionState ContinuousTestingMode="0" Name="CosmosProfileStoreTest" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <TestAncestor> + <TestId>xUnit::A7808CB8-B561-4939-9B4E-8AC0FD786DC3::net6.0::ChatService.Web.IntegrationTests.CosmosProfileStoreTest</TestId> + </TestAncestor> </SessionState> <SessionState ContinuousTestingMode="0" Name="GetProfile_Success" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <TestAncestor> From 62ebf4853453e1c3def893bae8bb03a4cbef6c48 Mon Sep 17 00:00:00 2001 From: nadimakk Date: Wed, 22 Feb 2023 21:21:53 +0200 Subject: [PATCH 10/45] add tests for ImageExists from BlobStorage --- .../BlobImageStoreTests.cs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/ChatService.Web.IntegrationTests/BlobImageStoreTests.cs b/ChatService.Web.IntegrationTests/BlobImageStoreTests.cs index 0f16514..77776fe 100644 --- a/ChatService.Web.IntegrationTests/BlobImageStoreTests.cs +++ b/ChatService.Web.IntegrationTests/BlobImageStoreTests.cs @@ -1,4 +1,5 @@ using System.Text; +using Azure; using ChatService.Web.Dtos; using ChatService.Web.Storage; using Microsoft.AspNetCore.Mvc.Testing; @@ -63,7 +64,7 @@ public async Task DownloadImage_Success() public async Task DownloadImage_NotFound() { var downloadedImage = await _store.DownloadImage("dummy_id"); - + Assert.Null(downloadedImage); } @@ -79,4 +80,20 @@ public async Task DeleteImage_Failure() { Assert.False(await _store.DeleteImage("dummy_id")); } + + [Fact] + public async Task ImageExists_Exists() + { + string imageId = await _store.UploadImage(_image); + + Assert.True(await _store.ImageExists(imageId)); + + _imageId = imageId; + } + + [Fact] + public async Task ImageExists_DoesntExist() + { + Assert.False(await _store.ImageExists("dummy_id")); + } } \ No newline at end of file From dfe4f8e67e71efb032906654b5bc47287df8369d Mon Sep 17 00:00:00 2001 From: Ali Date: Sun, 26 Feb 2023 16:21:29 +0200 Subject: [PATCH 11/45] Create ProfileService with Tests --- .../Services/ProfileServiceTests.cs | 108 ++++++++++++++++++ ChatService.Web/Program.cs | 2 + ChatService.Web/Services/IProfileService.cs | 10 ++ ChatService.Web/Services/ProfileService.cs | 51 +++++++++ 4 files changed, 171 insertions(+) create mode 100644 ChatService.Web.Tests/Services/ProfileServiceTests.cs create mode 100644 ChatService.Web/Services/IProfileService.cs create mode 100644 ChatService.Web/Services/ProfileService.cs diff --git a/ChatService.Web.Tests/Services/ProfileServiceTests.cs b/ChatService.Web.Tests/Services/ProfileServiceTests.cs new file mode 100644 index 0000000..aa57fe4 --- /dev/null +++ b/ChatService.Web.Tests/Services/ProfileServiceTests.cs @@ -0,0 +1,108 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Services; +using ChatService.Web.Storage; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace ChatService.Web.Tests.Services; + +public class ProfileServiceTests : IClassFixture> +{ + private readonly Mock _profileStoreMock = new(); + private readonly Mock _imageStoreMock = new(); + private readonly IProfileService _profileService; + + private readonly Profile _profile = new Profile("foobar", "Foo", "Bar", "123"); + + public ProfileServiceTests(WebApplicationFactory factory) + { + _profileService = factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddSingleton(_profileStoreMock.Object); + services.AddSingleton(_imageStoreMock.Object); + }); + }).Services.GetRequiredService(); + } + + [Fact] + public async Task GetProfile_Success() + { + _profileStoreMock.Setup(m => m.GetProfile(_profile.username)) + .ReturnsAsync(_profile); + + var receivedProfile = await _profileService.GetProfile(_profile.username); + + Assert.Equal(_profile, receivedProfile); + } + + [Fact] + public async Task AddNewProfile_Success() + { + _imageStoreMock.Setup(m => m.ImageExists(_profile.profilePictureId)) + .ReturnsAsync(true); + + await _profileService.AddProfile(_profile); + + _profileStoreMock.Verify(m => m.AddProfile(_profile), Times.Once); + } + + [Fact] + public async Task AddNewProfile_NullProfile() + { + await Assert.ThrowsAsync( async () => await _profileService.AddProfile(null)); + } + + [Theory] + [InlineData(null, "Foo", "Bar", "dummy_id")] + [InlineData("", "Foo", "Bar", "dummy_id")] + [InlineData(" ", "Foo", "Bar", "dummy_id")] + [InlineData("foobar", null, "Bar", "dummy_id")] + [InlineData("foobar", "", "Bar", "dummy_id")] + [InlineData("foobar", " ", "Bar", "dummy_id")] + [InlineData("foobar", "Foo", null, "dummy_id")] + [InlineData("foobar", "Foo", "", "dummy_id")] + [InlineData("foobar", "Foo", " ", "dummy_id")] + [InlineData("foobar", "Foo", "Bar", null)] + [InlineData("foobar", "Foo", "Bar","")] + [InlineData("foobar", "Foo", "Bar"," ")] + public async Task AddNewProfile_InvalidArgs(string username, string firstName, string lastName, string profilePictureId) + { + Profile profile = new(username, firstName, lastName, profilePictureId); + await Assert.ThrowsAsync( async () => await _profileService.AddProfile(profile)); + } + + [Fact] + public async Task AddNewProfile_ProfilePictureNotFound() + { + _imageStoreMock.Setup(m => m.ImageExists(_profile.profilePictureId)) + .ReturnsAsync(false); + + await Assert.ThrowsAsync( async () => await _profileService.AddProfile(_profile)); + } + + [Fact] + public async Task DeleteProfile_Success() + { + _profileStoreMock.Setup(m => m.GetProfile(_profile.username)) + .ReturnsAsync(_profile); + + await _profileService.DeleteProfile(_profile.username); + + _imageStoreMock.Verify(m => m.DeleteImage(_profile.profilePictureId), Times.Once); + _profileStoreMock.Verify(m => m.DeleteProfile(_profile.username), Times.Once); + } + + [Fact] + public async Task DeleteProfile_ProfileNotFound() + { + await Assert.ThrowsAsync( + async () => await _profileService.DeleteProfile(_profile.username)); + + _imageStoreMock.Verify(m => m.DeleteImage(_profile.profilePictureId), Times.Never); + _profileStoreMock.Verify(m => m.DeleteProfile(_profile.username), Times.Never); + } +} \ No newline at end of file diff --git a/ChatService.Web/Program.cs b/ChatService.Web/Program.cs index e7dd3e3..739ce75 100644 --- a/ChatService.Web/Program.cs +++ b/ChatService.Web/Program.cs @@ -1,5 +1,6 @@ using Azure.Storage.Blobs; using ChatService.Web.Configuration; +using ChatService.Web.Services; using ChatService.Web.Storage; using Microsoft.Azure.Cosmos; using Microsoft.Extensions.Options; @@ -24,6 +25,7 @@ return new BlobServiceClient(blobOptions.Value.ConnectionString); } ); +builder.Services.AddSingleton(); builder.Services.AddControllers(); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle diff --git a/ChatService.Web/Services/IProfileService.cs b/ChatService.Web/Services/IProfileService.cs new file mode 100644 index 0000000..6eb6662 --- /dev/null +++ b/ChatService.Web/Services/IProfileService.cs @@ -0,0 +1,10 @@ +using ChatService.Web.Dtos; + +namespace ChatService.Web.Services; + +public interface IProfileService +{ + Task GetProfile(string username); + Task AddProfile(Profile profile); + Task DeleteProfile(string username); +} \ 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..3e5780e --- /dev/null +++ b/ChatService.Web/Services/ProfileService.cs @@ -0,0 +1,51 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Storage; + +namespace ChatService.Web.Services; + +public class ProfileService : IProfileService +{ + private readonly IProfileStore _profileStore; + private readonly IImageStore _imageStore; + + public ProfileService(IProfileStore profileStore, IImageStore imageStore) + { + _profileStore = profileStore; + _imageStore = imageStore; + } + + public async Task GetProfile(string username) + { + return await _profileStore.GetProfile(username); + } + + public async Task AddProfile(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)); + } + bool imageExists = await _imageStore.ImageExists(profile.profilePictureId); + if (!imageExists) + { + throw new ArgumentException("The profile picture of the profile does not exist."); + } + await _profileStore.AddProfile(profile); + } + + public async Task DeleteProfile(string username) + { + Profile? profile = await GetProfile(username); + if (profile == null) + { + throw new ArgumentException($"Profile with username {username} doesn't exist."); + } + await _imageStore.DeleteImage(profile.profilePictureId); + await _profileStore.DeleteProfile(username); + } +} \ No newline at end of file From b37371fdbf9ce575023655f804850d376bc98cc6 Mon Sep 17 00:00:00 2001 From: Ali Date: Sun, 26 Feb 2023 17:51:22 +0200 Subject: [PATCH 12/45] Edit relevant classes and tests to incorporate ProfileService --- .../CosmosProfileStoreTests.cs | 46 +++++----------- .../Controllers/ProfileControllerTests.cs | 44 ++++++--------- .../Services/ProfileServiceTests.cs | 13 +++++ .../Controllers/ProfileController.cs | 37 +++++++------ ChatService.Web/Services/ProfileService.cs | 10 +++- ChatService.Web/Storage/CosmosProfileStore.cs | 55 ++++++++++++------- ChatService.Web/Storage/IProfileStore.cs | 1 + 7 files changed, 110 insertions(+), 96 deletions(-) diff --git a/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs b/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs index a1af821..5fb77d5 100644 --- a/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs +++ b/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs @@ -1,17 +1,13 @@ using ChatService.Web.Dtos; using ChatService.Web.Storage; using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Azure.Cosmos; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Moq; namespace ChatService.Web.IntegrationTests; public class CosmosProfileStoreTest : IClassFixture>, IAsyncLifetime { private readonly IProfileStore _store; - private readonly Mock _imageStoreMock = new(); private readonly Profile _profile = new( username: Guid.NewGuid().ToString(), @@ -32,27 +28,12 @@ public async Task DisposeAsync() public CosmosProfileStoreTest(WebApplicationFactory factory) { - var config = new ConfigurationBuilder() - .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) - .Build(); - - string connectionString = config.GetSection("Cosmos").GetValue("ConnectionString"); - - var services = new ServiceCollection(); - services.AddSingleton(_imageStoreMock.Object); - services.AddSingleton(new CosmosClient(connectionString)); - services.AddSingleton(); - - var serviceProvider = services.BuildServiceProvider(); - _store = serviceProvider.GetRequiredService(); + _store = factory.Services.GetRequiredService(); } [Fact] public async Task AddNewProfile_Success() { - _imageStoreMock.Setup(m => m.ImageExists(_profile.profilePictureId)) - .ReturnsAsync(true); - await _store.AddProfile(_profile); Assert.Equal(_profile, await _store.GetProfile(_profile.username)); } @@ -82,15 +63,6 @@ public async Task AddNewProfile_NullProfile() await Assert.ThrowsAsync( async () => await _store.AddProfile(null)); } - [Fact] - public async Task AddNewProfile_ProfilePictureNotFound() - { - _imageStoreMock.Setup(m => m.ImageExists(_profile.profilePictureId)) - .ReturnsAsync(false); - - await Assert.ThrowsAsync( async () => await _store.AddProfile(_profile)); - } - [Fact] public async Task GetNonExistingProfile() { @@ -100,12 +72,22 @@ public async Task GetNonExistingProfile() [Fact] public async Task DeleteProfile() { - _imageStoreMock.Setup(m => m.ImageExists(_profile.profilePictureId)) - .ReturnsAsync(true); - await _store.AddProfile(_profile); Assert.Equal(_profile, await _store.GetProfile(_profile.username)); await _store.DeleteProfile(_profile.username); Assert.Null(await _store.GetProfile(_profile.username)); } + + [Fact] + public async Task ProfileExists_Exists() + { + await _store.AddProfile(_profile); + Assert.True(await _store.ProfileExists(_profile.username)); + } + + [Fact] + public async Task ProfileExists_DoesNotExist() + { + Assert.False(await _store.ProfileExists(_profile.username)); + } } \ No newline at end of file diff --git a/ChatService.Web.Tests/Controllers/ProfileControllerTests.cs b/ChatService.Web.Tests/Controllers/ProfileControllerTests.cs index 5444609..d9d4c34 100644 --- a/ChatService.Web.Tests/Controllers/ProfileControllerTests.cs +++ b/ChatService.Web.Tests/Controllers/ProfileControllerTests.cs @@ -1,7 +1,7 @@ using System.Net; using System.Net.Http.Json; using ChatService.Web.Dtos; -using ChatService.Web.Storage; +using ChatService.Web.Services; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; @@ -12,8 +12,7 @@ namespace ChatService.Web.Tests.Controllers; public class ProfileControllerTests : IClassFixture> { - private readonly Mock _profileStoreMock = new(); - private readonly Mock _imageStoreMock = new(); + private readonly Mock _profileServiceMock = new(); private readonly HttpClient _httpClient; private readonly Profile _profile = new Profile("foobar", "Foo", "Bar", "123"); @@ -23,8 +22,7 @@ public ProfileControllerTests(WebApplicationFactory factory) { builder.ConfigureTestServices(services => { - services.AddSingleton(_profileStoreMock.Object); - services.AddSingleton(_imageStoreMock.Object); + services.AddSingleton(_profileServiceMock.Object); }); }).CreateClient(); } @@ -32,7 +30,7 @@ public ProfileControllerTests(WebApplicationFactory factory) [Fact] public async Task GetProfile_Success() { - _profileStoreMock.Setup(m => m.GetProfile(_profile.username)) + _profileServiceMock.Setup(m => m.GetProfile(_profile.username)) .ReturnsAsync(_profile); var response = await _httpClient.GetAsync($"/Profile/{_profile.username}"); @@ -47,7 +45,7 @@ public async Task GetProfile_Success() [Fact] public async Task GetProfile_ProfileNotFound() { - _profileStoreMock.Setup(m => m.GetProfile(_profile.username)) + _profileServiceMock.Setup(m => m.GetProfile(_profile.username)) .ReturnsAsync((Profile?) null); var response = await _httpClient.GetAsync($"/Profile/{_profile.username}"); @@ -61,36 +59,32 @@ public async Task GetProfile_ProfileNotFound() [Fact] public async Task PostProfile_Success() { - _profileStoreMock.Setup(m => m.GetProfile(_profile.username)) - .ReturnsAsync((Profile?) null); - _imageStoreMock.Setup(m => m.ImageExists(_profile.profilePictureId)) - .ReturnsAsync(true); - var response = await _httpClient.PostAsJsonAsync("/Profile/", _profile); Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.Equal($"http://localhost/Profile/{_profile.username}", + response.Headers.GetValues("Location").First()); + var json = await response.Content.ReadAsStringAsync(); var receivedProfile = JsonConvert.DeserializeObject(json); Assert.Equal(_profile, receivedProfile); - _profileStoreMock.Verify(mock => mock.AddProfile(_profile), Times.Once); + _profileServiceMock.Verify(mock => mock.AddProfile(_profile), Times.Once); } [Fact] public async Task PostProfile_UsernameTaken() { - _profileStoreMock.Setup(m => m.GetProfile(_profile.username)) - .ReturnsAsync(_profile); + _profileServiceMock.Setup(m => m.AddProfile(_profile)) + .ThrowsAsync(new ArgumentException($"The username {_profile.username} is taken.")); var response = await _httpClient.PostAsJsonAsync("/Profile/", _profile); Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); var json = await response.Content.ReadAsStringAsync(); - Assert.Equal($"A user with the username {_profile.username} already exist.", json); - - _profileStoreMock.Verify(mock => mock.AddProfile(_profile), Times.Never); + Assert.Equal($"The username {_profile.username} is taken.", json); } [Theory] @@ -108,26 +102,24 @@ public async Task PostProfile_UsernameTaken() [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, firstName, lastName, profilePictureId); var response = await _httpClient.PostAsJsonAsync("/Profile", profile); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - _profileStoreMock.Verify(mock => mock.AddProfile(_profile), Times.Never); } [Fact] public async Task ProfileProfile_ProfilePictureNotFound() { - _profileStoreMock.Setup(m => m.GetProfile(_profile.username)) - .ReturnsAsync((Profile?) null); - _imageStoreMock.Setup(m => m.ImageExists(_profile.profilePictureId)) - .ReturnsAsync(false); - + _profileServiceMock.Setup(m => m.AddProfile(_profile)) + .ThrowsAsync(new ArgumentException("Invalid profile picture ID.")); + var response = await _httpClient.PostAsJsonAsync("/Profile/", _profile); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - - _profileStoreMock.Verify(mock => mock.AddProfile(_profile), Times.Never); } } \ No newline at end of file diff --git a/ChatService.Web.Tests/Services/ProfileServiceTests.cs b/ChatService.Web.Tests/Services/ProfileServiceTests.cs index aa57fe4..38bcf4d 100644 --- a/ChatService.Web.Tests/Services/ProfileServiceTests.cs +++ b/ChatService.Web.Tests/Services/ProfileServiceTests.cs @@ -42,6 +42,8 @@ public async Task GetProfile_Success() [Fact] public async Task AddNewProfile_Success() { + _profileStoreMock.Setup(m => m.ProfileExists(_profile.username)) + .ReturnsAsync(false); _imageStoreMock.Setup(m => m.ImageExists(_profile.profilePictureId)) .ReturnsAsync(true); @@ -75,6 +77,17 @@ public async Task AddNewProfile_InvalidArgs(string username, string firstName, s await Assert.ThrowsAsync( async () => await _profileService.AddProfile(profile)); } + [Fact] + public async Task AddNewProfile_UsernameTaken() + { + _profileStoreMock.Setup(m => m.ProfileExists(_profile.username)) + .ReturnsAsync(true); + + await Assert.ThrowsAsync( async () => await _profileService.AddProfile(_profile)); + + _profileStoreMock.Verify(m => m.AddProfile(_profile), Times.Never); + } + [Fact] public async Task AddNewProfile_ProfilePictureNotFound() { diff --git a/ChatService.Web/Controllers/ProfileController.cs b/ChatService.Web/Controllers/ProfileController.cs index f2206fd..b204b6e 100644 --- a/ChatService.Web/Controllers/ProfileController.cs +++ b/ChatService.Web/Controllers/ProfileController.cs @@ -1,5 +1,5 @@ using ChatService.Web.Dtos; -using ChatService.Web.Storage; +using ChatService.Web.Services; using Microsoft.AspNetCore.Mvc; namespace ChatService.Web.Controllers; @@ -8,19 +8,17 @@ namespace ChatService.Web.Controllers; [Route("[controller]")] public class ProfileController : ControllerBase { - private readonly IProfileStore _profileStore; - private readonly IImageStore _imageStore; - - public ProfileController(IProfileStore profileStore, IImageStore imageStore) + private readonly IProfileService _profileService; + + public ProfileController(IProfileService profileService) { - _profileStore = profileStore; - _imageStore = imageStore; + _profileService = profileService; } [HttpGet("{username}")] public async Task> GetProfile(string username) { - var profile = await _profileStore.GetProfile(username); + var profile = await _profileService.GetProfile(username); if (profile == null) { return NotFound($"A profile with the username {username} was not found."); @@ -32,19 +30,22 @@ public async Task> GetProfile(string username) [HttpPost] public async Task> PostProfile(Profile profile) { - var isUsernameTaken = await _profileStore.GetProfile(profile.username) != null; - if (isUsernameTaken) + try { - return Conflict($"A user with the username {profile.username} already exist."); + await _profileService.AddProfile(profile); + return CreatedAtAction(nameof(GetProfile), new { username = profile.username }, profile); } - - bool imageExists = await _imageStore.ImageExists(profile.profilePictureId); - if (!imageExists) + catch (ArgumentException e) { - return BadRequest("The profile picture of the profile does not exist."); + if (e.Message == $"The username {profile.username} is taken.") + { + return Conflict(e.Message); + } + if (e.Message == $"Invalid profile {profile}" || e.Message == "Invalid profile picture ID.") + { + return BadRequest(e.Message); + } + throw; } - - await _profileStore.AddProfile(profile); - return CreatedAtAction(nameof(GetProfile), new { username = profile.username }, profile); } } \ No newline at end of file diff --git a/ChatService.Web/Services/ProfileService.cs b/ChatService.Web/Services/ProfileService.cs index 3e5780e..0d2effc 100644 --- a/ChatService.Web/Services/ProfileService.cs +++ b/ChatService.Web/Services/ProfileService.cs @@ -30,11 +30,19 @@ public async Task AddProfile(Profile profile) { throw new ArgumentException($"Invalid profile {profile}", nameof(profile)); } + + bool usernameTaken = await _profileStore.ProfileExists(profile.username); + if (usernameTaken) + { + throw new ArgumentException($"The username {profile.username} is taken."); + } + bool imageExists = await _imageStore.ImageExists(profile.profilePictureId); if (!imageExists) { - throw new ArgumentException("The profile picture of the profile does not exist."); + throw new ArgumentException("Invalid profile picture ID."); } + await _profileStore.AddProfile(profile); } diff --git a/ChatService.Web/Storage/CosmosProfileStore.cs b/ChatService.Web/Storage/CosmosProfileStore.cs index ced9603..420c568 100644 --- a/ChatService.Web/Storage/CosmosProfileStore.cs +++ b/ChatService.Web/Storage/CosmosProfileStore.cs @@ -8,12 +8,10 @@ namespace ChatService.Web.Storage; public class CosmosProfileStore : IProfileStore { private readonly CosmosClient _cosmosClient; - private readonly IImageStore _imageStore; - + public CosmosProfileStore(CosmosClient cosmosClient, IImageStore imageStore) { _cosmosClient = cosmosClient; - _imageStore = imageStore; } private Container Container => _cosmosClient.GetDatabase("chatService").GetContainer("profiles"); @@ -29,15 +27,8 @@ public async Task AddProfile(Profile profile) { throw new ArgumentException($"Invalid profile {profile}", nameof(profile)); } - - bool imageExists = await _imageStore.ImageExists(profile.profilePictureId); - if (!imageExists) - { - throw new ArgumentException("The profile picture of the profile does not exist."); - } await Container.UpsertItemAsync(ToEntity(profile)); - } public async Task GetProfile(string username) @@ -66,18 +57,44 @@ public async Task AddProfile(Profile profile) public async Task DeleteProfile(string username) { - Profile? profile = await GetProfile(username); - - if (profile == null) + try + { + await Container.DeleteItemAsync( + id: username, + partitionKey: new PartitionKey(username)); + } + catch (CosmosException e) { - return; + if (e.StatusCode == HttpStatusCode.NotFound) + { + return; + } + throw; } + } - await _imageStore.DeleteImage(profile.profilePictureId); - - await Container.DeleteItemAsync( - id: username, - partitionKey: new PartitionKey(username)); + public async Task ProfileExists(string username) + { + try + { + await Container.ReadItemAsync( + id: username, + partitionKey: new PartitionKey(username), + new ItemRequestOptions + { + ConsistencyLevel = ConsistencyLevel.Session + } + ); + return true; + } + catch (CosmosException e) + { + if (e.StatusCode == HttpStatusCode.NotFound) + { + return false; + } + throw; + } } private static ProfileEntity ToEntity(Profile profile) diff --git a/ChatService.Web/Storage/IProfileStore.cs b/ChatService.Web/Storage/IProfileStore.cs index cdc7ab9..a0104f7 100644 --- a/ChatService.Web/Storage/IProfileStore.cs +++ b/ChatService.Web/Storage/IProfileStore.cs @@ -7,4 +7,5 @@ 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 From 2d9b7b442e61ab2c07f7a56fad49ca4521cf638e Mon Sep 17 00:00:00 2001 From: Ali Date: Wed, 1 Mar 2023 17:35:50 +0200 Subject: [PATCH 13/45] Create custom exceptions --- .../CosmosProfileStoreTests.cs | 8 ++++ .../Controllers/ProfileControllerTests.cs | 10 ++-- .../Services/ProfileServiceTests.cs | 11 +++-- .../Controllers/ProfileController.cs | 17 +++---- .../Exceptions/ImageNotFoundException.cs | 8 ++++ .../Exceptions/ProfileExistsException.cs | 8 ++++ ChatService.Web/Services/ProfileService.cs | 9 +--- ChatService.Web/Storage/CosmosProfileStore.cs | 16 ++++++- ChatService.sln.DotSettings.user | 48 +++++-------------- 9 files changed, 70 insertions(+), 65 deletions(-) create mode 100644 ChatService.Web/Exceptions/ImageNotFoundException.cs create mode 100644 ChatService.Web/Exceptions/ProfileExistsException.cs diff --git a/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs b/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs index 5fb77d5..aadf0fc 100644 --- a/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs +++ b/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs @@ -1,4 +1,5 @@ using ChatService.Web.Dtos; +using ChatService.Web.Exceptions; using ChatService.Web.Storage; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; @@ -63,6 +64,13 @@ public async Task AddNewProfile_NullProfile() await Assert.ThrowsAsync( async () => await _store.AddProfile(null)); } + [Fact] + public async Task AddNewProfile_UsernameTaken() + { + await _store.AddProfile(_profile); + await Assert.ThrowsAsync( async () => await _store.AddProfile(_profile)); + } + [Fact] public async Task GetNonExistingProfile() { diff --git a/ChatService.Web.Tests/Controllers/ProfileControllerTests.cs b/ChatService.Web.Tests/Controllers/ProfileControllerTests.cs index d9d4c34..fa581ec 100644 --- a/ChatService.Web.Tests/Controllers/ProfileControllerTests.cs +++ b/ChatService.Web.Tests/Controllers/ProfileControllerTests.cs @@ -1,6 +1,7 @@ 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; @@ -77,14 +78,11 @@ public async Task PostProfile_Success() public async Task PostProfile_UsernameTaken() { _profileServiceMock.Setup(m => m.AddProfile(_profile)) - .ThrowsAsync(new ArgumentException($"The username {_profile.username} is taken.")); - + .ThrowsAsync(new UsernameTakenException($"The username {_profile.username} is taken.")); + var response = await _httpClient.PostAsJsonAsync("/Profile/", _profile); Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); - - var json = await response.Content.ReadAsStringAsync(); - Assert.Equal($"The username {_profile.username} is taken.", json); } [Theory] @@ -116,7 +114,7 @@ public async Task PostProfile_InvalidArguments(string username, string firstName public async Task ProfileProfile_ProfilePictureNotFound() { _profileServiceMock.Setup(m => m.AddProfile(_profile)) - .ThrowsAsync(new ArgumentException("Invalid profile picture ID.")); + .ThrowsAsync(new ImageNotFoundException("Invalid profile picture ID.")); var response = await _httpClient.PostAsJsonAsync("/Profile/", _profile); diff --git a/ChatService.Web.Tests/Services/ProfileServiceTests.cs b/ChatService.Web.Tests/Services/ProfileServiceTests.cs index 38bcf4d..0d8809e 100644 --- a/ChatService.Web.Tests/Services/ProfileServiceTests.cs +++ b/ChatService.Web.Tests/Services/ProfileServiceTests.cs @@ -1,4 +1,5 @@ using ChatService.Web.Dtos; +using ChatService.Web.Exceptions; using ChatService.Web.Services; using ChatService.Web.Storage; using Microsoft.AspNetCore.Mvc.Testing; @@ -80,12 +81,12 @@ public async Task AddNewProfile_InvalidArgs(string username, string firstName, s [Fact] public async Task AddNewProfile_UsernameTaken() { - _profileStoreMock.Setup(m => m.ProfileExists(_profile.username)) + _imageStoreMock.Setup(m => m.ImageExists(_profile.profilePictureId)) .ReturnsAsync(true); + _profileStoreMock.Setup(m => m.AddProfile(_profile)) + .ThrowsAsync(new UsernameTakenException($"A profile with username {_profile.username} already exists.")); - await Assert.ThrowsAsync( async () => await _profileService.AddProfile(_profile)); - - _profileStoreMock.Verify(m => m.AddProfile(_profile), Times.Never); + await Assert.ThrowsAsync( async () => await _profileService.AddProfile(_profile)); } [Fact] @@ -94,7 +95,7 @@ public async Task AddNewProfile_ProfilePictureNotFound() _imageStoreMock.Setup(m => m.ImageExists(_profile.profilePictureId)) .ReturnsAsync(false); - await Assert.ThrowsAsync( async () => await _profileService.AddProfile(_profile)); + await Assert.ThrowsAsync( async () => await _profileService.AddProfile(_profile)); } [Fact] diff --git a/ChatService.Web/Controllers/ProfileController.cs b/ChatService.Web/Controllers/ProfileController.cs index b204b6e..af32490 100644 --- a/ChatService.Web/Controllers/ProfileController.cs +++ b/ChatService.Web/Controllers/ProfileController.cs @@ -1,4 +1,5 @@ using ChatService.Web.Dtos; +using ChatService.Web.Exceptions; using ChatService.Web.Services; using Microsoft.AspNetCore.Mvc; @@ -35,17 +36,13 @@ public async Task> PostProfile(Profile profile) await _profileService.AddProfile(profile); return CreatedAtAction(nameof(GetProfile), new { username = profile.username }, profile); } - catch (ArgumentException e) + catch (Exception e) when (e is ArgumentException || e is ImageNotFoundException) { - if (e.Message == $"The username {profile.username} is taken.") - { - return Conflict(e.Message); - } - if (e.Message == $"Invalid profile {profile}" || e.Message == "Invalid profile picture ID.") - { - return BadRequest(e.Message); - } - throw; + return BadRequest(e.Message); + } + catch (UsernameTakenException e) + { + return Conflict(e.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/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/Services/ProfileService.cs b/ChatService.Web/Services/ProfileService.cs index 0d2effc..e1fda60 100644 --- a/ChatService.Web/Services/ProfileService.cs +++ b/ChatService.Web/Services/ProfileService.cs @@ -1,4 +1,5 @@ using ChatService.Web.Dtos; +using ChatService.Web.Exceptions; using ChatService.Web.Storage; namespace ChatService.Web.Services; @@ -31,16 +32,10 @@ public async Task AddProfile(Profile profile) throw new ArgumentException($"Invalid profile {profile}", nameof(profile)); } - bool usernameTaken = await _profileStore.ProfileExists(profile.username); - if (usernameTaken) - { - throw new ArgumentException($"The username {profile.username} is taken."); - } - bool imageExists = await _imageStore.ImageExists(profile.profilePictureId); if (!imageExists) { - throw new ArgumentException("Invalid profile picture ID."); + throw new ImageNotFoundException("Invalid profile picture ID."); } await _profileStore.AddProfile(profile); diff --git a/ChatService.Web/Storage/CosmosProfileStore.cs b/ChatService.Web/Storage/CosmosProfileStore.cs index 420c568..407e45c 100644 --- a/ChatService.Web/Storage/CosmosProfileStore.cs +++ b/ChatService.Web/Storage/CosmosProfileStore.cs @@ -1,5 +1,6 @@ using System.Net; using ChatService.Web.Dtos; +using ChatService.Web.Exceptions; using ChatService.Web.Storage.Entities; using Microsoft.Azure.Cosmos; @@ -27,8 +28,19 @@ public async Task AddProfile(Profile profile) { throw new ArgumentException($"Invalid profile {profile}", nameof(profile)); } - - await Container.UpsertItemAsync(ToEntity(profile)); + + try + { + await Container.CreateItemAsync(ToEntity(profile), new PartitionKey(profile.username)); + } + catch (CosmosException e) + { + if (e.StatusCode == HttpStatusCode.Conflict) + { + throw new UsernameTakenException($"A profile with username {profile.username} already exists."); + } + throw; + } } public async Task GetProfile(string username) diff --git a/ChatService.sln.DotSettings.user b/ChatService.sln.DotSettings.user index 3d8a1fa..fb3a1d4 100644 --- a/ChatService.sln.DotSettings.user +++ b/ChatService.sln.DotSettings.user @@ -3,39 +3,17 @@ On On On - C:\Users\nadim\AppData\Local\JetBrains\Rider2022.3\resharper-host\temp\Rider\vAny\CoverageData\_ChatService.34854384\Snapshot\snapshot.utdcvr - - <SessionState ContinuousTestingMode="0" Name="UploadImage_Success" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <TestAncestor> - <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ImageControllerTests.UploadImage_Success</TestId> - </TestAncestor> -</SessionState> - <SessionState ContinuousTestingMode="0" Name="CosmosProfileStoreTest" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <TestAncestor> - <TestId>xUnit::A7808CB8-B561-4939-9B4E-8AC0FD786DC3::net6.0::ChatService.Web.IntegrationTests.CosmosProfileStoreTest</TestId> - </TestAncestor> -</SessionState> - <SessionState ContinuousTestingMode="0" Name="GetProfile_Success" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <TestAncestor> - <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ProfileControllerTests.GetProfile_Success</TestId> - <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ProfileControllerTests.GetProfile_ProfileNotFound</TestId> - <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ProfileControllerTests.PostProfile_Success</TestId> - <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ProfileControllerTests.PostProfile_UsernameTaken</TestId> - <TestId>xUnit::A7808CB8-B561-4939-9B4E-8AC0FD786DC3::net6.0::ChatService.Web.IntegrationTests.CosmosProfileStoreTest</TestId> - <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ProfileControllerTests</TestId> - <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ImageControllerTests.UploadImage_Success</TestId> - </TestAncestor> + <SessionState ContinuousTestingMode="0" IsActive="True" Name="Session" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Or> + <Project Location="C:\Users\HP\Desktop\EECE503E\Codes\ChatService\ChatService\ChatService.Web.IntegrationTests" Presentation="&lt;ChatService.Web.IntegrationTests&gt;" /> + <Project Location="C:\Users\HP\Desktop\EECE503E\Codes\ChatService\ChatService\ChatService.Web.Tests" Presentation="&lt;ChatService.Web.Tests&gt;" /> + </Or> </SessionState> - <SessionState ContinuousTestingMode="0" Name="UploadImage_Success #2" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <Solution /> -</SessionState> - <SessionState ContinuousTestingMode="0" IsActive="True" Name="UploadImage_Success #3" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <Solution /> -</SessionState> - <SessionState ContinuousTestingMode="0" Name="AddNewProfile_InvalidArgs" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <TestAncestor> - <TestId>xUnit::A7808CB8-B561-4939-9B4E-8AC0FD786DC3::net6.0::ChatService.Web.IntegrationTests.CosmosProfileStoreTest.AddNewProfile_InvalidArgs</TestId> - <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ImageControllerTests.UploadImage_Success</TestId> - <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ImageControllerTests</TestId> - </TestAncestor> -</SessionState> \ No newline at end of file + + + + + + + + \ No newline at end of file From 7cf620635f6411699d589cc3a192c21178ea4c0c Mon Sep 17 00:00:00 2001 From: Ali Jaafar <113817099+AliJaafar21@users.noreply.github.com> Date: Wed, 8 Mar 2023 13:39:27 +0200 Subject: [PATCH 14/45] Create prbuild.yml --- .github/workflows/prbuild.yml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/prbuild.yml diff --git a/.github/workflows/prbuild.yml b/.github/workflows/prbuild.yml new file mode 100644 index 0000000..c41195e --- /dev/null +++ b/.github/workflows/prbuild.yml @@ -0,0 +1,32 @@ +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 --no-restore + + - name: Test + run: dotnet test --no-build --verbosity normal + env: + Cosmos:ConnectionString: ${{ secrets.COSMOS_CONNECTIONSTRING }} + BlobStorage:ConnectionString: ${{ secrets.BLOBSTORAGE_CONNECTIONSTRING }} From 17405d38cf57927d11be299deaa0fb2cab8f072f Mon Sep 17 00:00:00 2001 From: Ali Jaafar <113817099+AliJaafar21@users.noreply.github.com> Date: Wed, 8 Mar 2023 13:41:20 +0200 Subject: [PATCH 15/45] Create azure_deploy.yml --- .github/workflows/azure_deploy.yml | 47 ++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .github/workflows/azure_deploy.yml diff --git a/.github/workflows/azure_deploy.yml b/.github/workflows/azure_deploy.yml new file mode 100644 index 0000000..28931de --- /dev/null +++ b/.github/workflows/azure_deploy.yml @@ -0,0 +1,47 @@ +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/ChatService.Web.Tests.csproj + + - name: Run integration tests + run: dotnet test ChatService.Web.IntegrationTests/ChatService.Web.IntegrationTests.csproj + + - 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 }} From 4454fe3c7ccea940c071707c5e8f59f7178cba84 Mon Sep 17 00:00:00 2001 From: nadimakk <113820866+nadimakk@users.noreply.github.com> Date: Wed, 8 Mar 2023 13:48:25 +0200 Subject: [PATCH 16/45] Update prbuild.yml --- .github/workflows/prbuild.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/prbuild.yml b/.github/workflows/prbuild.yml index c41195e..51fac8a 100644 --- a/.github/workflows/prbuild.yml +++ b/.github/workflows/prbuild.yml @@ -25,8 +25,11 @@ jobs: - name: Build run: dotnet build --no-restore - - name: Test - run: dotnet test --no-build --verbosity normal + - name: Run unit tests + run: dotnet test ChatService.Web.Tests/ChatService.Web.Tests.csproj + + - name: Run integration tests + run: dotnet test ChatService.Web.IntegrationTests/ChatService.Web.IntegrationTests.csproj env: Cosmos:ConnectionString: ${{ secrets.COSMOS_CONNECTIONSTRING }} BlobStorage:ConnectionString: ${{ secrets.BLOBSTORAGE_CONNECTIONSTRING }} From 2945732ce5f20caf733ba441c135aa9216b4d336 Mon Sep 17 00:00:00 2001 From: Ali Jaafar <113817099+AliJaafar21@users.noreply.github.com> Date: Thu, 9 Mar 2023 15:25:20 +0200 Subject: [PATCH 17/45] Edit workflow azure_deploy.yml Run tests without building again. --- .github/workflows/azure_deploy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/azure_deploy.yml b/.github/workflows/azure_deploy.yml index 28931de..d179818 100644 --- a/.github/workflows/azure_deploy.yml +++ b/.github/workflows/azure_deploy.yml @@ -31,10 +31,10 @@ jobs: run: dotnet build ./ChatService.sln --configuration Release --no-restore - name: Run unit tests - run: dotnet test ChatService.Web.Tests/ChatService.Web.Tests.csproj + run: dotnet test ChatService.Web.Tests/bin/Release/net6.0/ChatService.Web.Tests.dll - name: Run integration tests - run: dotnet test ChatService.Web.IntegrationTests/ChatService.Web.IntegrationTests.csproj + run: dotnet test ChatService.Web.IntegrationTests/bin/Release/net6.0/ChatService.Web.IntegrationTests.dll - name: Publish run: dotnet publish --configuration Release --output '${{ env.AZURE_WEBAPP_PACKAGE_PATH }}' --no-restore ChatService.Web From 58c53ca94cb85c05e47551ce8171bef436a43cc6 Mon Sep 17 00:00:00 2001 From: nadimakk <113820866+nadimakk@users.noreply.github.com> Date: Thu, 9 Mar 2023 15:31:19 +0200 Subject: [PATCH 18/45] Edit workflow prbuild.yml Run tests without building again. --- .github/workflows/prbuild.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/prbuild.yml b/.github/workflows/prbuild.yml index 51fac8a..2276989 100644 --- a/.github/workflows/prbuild.yml +++ b/.github/workflows/prbuild.yml @@ -26,10 +26,10 @@ jobs: run: dotnet build --no-restore - name: Run unit tests - run: dotnet test ChatService.Web.Tests/ChatService.Web.Tests.csproj - + run: dotnet test ChatService.Web.Tests/bin/Release/net6.0/ChatService.Web.Tests.dll + - name: Run integration tests - run: dotnet test ChatService.Web.IntegrationTests/ChatService.Web.IntegrationTests.csproj + 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 }} From c4b52e99b546f0c9e3b07d3f62131405f9fcfb68 Mon Sep 17 00:00:00 2001 From: nadimakk <113820866+nadimakk@users.noreply.github.com> Date: Thu, 9 Mar 2023 15:33:53 +0200 Subject: [PATCH 19/45] Update workflow azure_deploy.yml Included connection strings in environment of integration tests to remove them from app_settings. --- .github/workflows/azure_deploy.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/azure_deploy.yml b/.github/workflows/azure_deploy.yml index d179818..e3e17ba 100644 --- a/.github/workflows/azure_deploy.yml +++ b/.github/workflows/azure_deploy.yml @@ -35,6 +35,9 @@ jobs: - 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 From 335e33527ac5e87332164becb1b8ad48cfc556c2 Mon Sep 17 00:00:00 2001 From: nadimakk Date: Thu, 9 Mar 2023 15:56:28 +0200 Subject: [PATCH 20/45] Remove connection strings from appsettings --- ChatService.Web/ChatService.Web.csproj | 1 + ChatService.Web/appsettings.json | 4 ++-- ChatService.sln.DotSettings.user | 8 ++++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/ChatService.Web/ChatService.Web.csproj b/ChatService.Web/ChatService.Web.csproj index e2aa6fb..8ff205e 100644 --- a/ChatService.Web/ChatService.Web.csproj +++ b/ChatService.Web/ChatService.Web.csproj @@ -11,5 +11,6 @@ + diff --git a/ChatService.Web/appsettings.json b/ChatService.Web/appsettings.json index 51777ea..5f6aee7 100644 --- a/ChatService.Web/appsettings.json +++ b/ChatService.Web/appsettings.json @@ -7,11 +7,11 @@ }, "Cosmos": { - "ConnectionString": "AccountEndpoint=https://chat-service.documents.azure.com:443/;AccountKey=bTPHlotBT0IYyYa4sFscBdFm3dzVpTexbtk8ygqhytijFYLpJAByf5CThrODbuDVvmTOcDio2ywEACDbCYlcOg==;" + "ConnectionString": "" }, "Blob": { - "ConnectionString": "DefaultEndpointsProtocol=https;AccountName=naa137;AccountKey=p6VJPQjkAPm3/JxBIY1l7Yj9ewb4Fp2l+Aciwm969OgT57+70hT/jMKcyDIdIwIJaEijfqsxAOdf+AStE/MZDw==;EndpointSuffix=core.windows.net" + "ConnectionString": "" }, "AllowedHosts": "*" diff --git a/ChatService.sln.DotSettings.user b/ChatService.sln.DotSettings.user index fb3a1d4..a482b95 100644 --- a/ChatService.sln.DotSettings.user +++ b/ChatService.sln.DotSettings.user @@ -3,12 +3,12 @@ On On On + C:\Users\nadim\AppData\Local\JetBrains\Rider2022.3\resharper-host\temp\Rider\vAny\CoverageData\_ChatService.34854384\Snapshot\snapshot.utdcvr <SessionState ContinuousTestingMode="0" IsActive="True" Name="Session" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <Or> - <Project Location="C:\Users\HP\Desktop\EECE503E\Codes\ChatService\ChatService\ChatService.Web.IntegrationTests" Presentation="&lt;ChatService.Web.IntegrationTests&gt;" /> - <Project Location="C:\Users\HP\Desktop\EECE503E\Codes\ChatService\ChatService\ChatService.Web.Tests" Presentation="&lt;ChatService.Web.Tests&gt;" /> - </Or> + <Solution /> </SessionState> + DefaultEndpointsProtocol=https;AccountName=naa137;AccountKey=p6VJPQjkAPm3/JxBIY1l7Yj9ewb4Fp2l+Aciwm969OgT57+70hT/jMKcyDIdIwIJaEijfqsxAOdf+AStE/MZDw==;EndpointSuffix=core.windows.net + AccountEndpoint=https://chat-service.documents.azure.com:443/;AccountKey=bTPHlotBT0IYyYa4sFscBdFm3dzVpTexbtk8ygqhytijFYLpJAByf5CThrODbuDVvmTOcDio2ywEACDbCYlcOg==; From a7decb154dd7b166d21a298fbdc29ae84603db56 Mon Sep 17 00:00:00 2001 From: nadimakk <113820866+nadimakk@users.noreply.github.com> Date: Thu, 9 Mar 2023 16:00:46 +0200 Subject: [PATCH 21/45] Update prbuild.yml --- .github/workflows/prbuild.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/prbuild.yml b/.github/workflows/prbuild.yml index 2276989..71fe75b 100644 --- a/.github/workflows/prbuild.yml +++ b/.github/workflows/prbuild.yml @@ -23,7 +23,7 @@ jobs: run: dotnet restore - name: Build - run: dotnet build --no-restore + 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 From 270dea7910499736adeb7d4ac91a5be14535f168 Mon Sep 17 00:00:00 2001 From: nadimakk Date: Thu, 9 Mar 2023 16:07:14 +0200 Subject: [PATCH 22/45] rename Blob connection string name --- ChatService.Web/appsettings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ChatService.Web/appsettings.json b/ChatService.Web/appsettings.json index 5f6aee7..16ab155 100644 --- a/ChatService.Web/appsettings.json +++ b/ChatService.Web/appsettings.json @@ -10,7 +10,7 @@ "ConnectionString": "" }, - "Blob": { + "BlobStorage": { "ConnectionString": "" }, From 6623f7c471baa7a25097fb777414dcc335a0aeb8 Mon Sep 17 00:00:00 2001 From: nadimakk Date: Thu, 9 Mar 2023 16:15:35 +0200 Subject: [PATCH 23/45] fix BlobStorage name in Program.cs --- ChatService.Web/Program.cs | 2 +- ChatService.sln.DotSettings.user | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/ChatService.Web/Program.cs b/ChatService.Web/Program.cs index 739ce75..47b001d 100644 --- a/ChatService.Web/Program.cs +++ b/ChatService.Web/Program.cs @@ -9,7 +9,7 @@ // Add Configuration builder.Services.Configure(builder.Configuration.GetSection("Cosmos")); -builder.Services.Configure(builder.Configuration.GetSection("Blob")); +builder.Services.Configure(builder.Configuration.GetSection("BlobStorage")); // Add services to the container. builder.Services.AddSingleton(); diff --git a/ChatService.sln.DotSettings.user b/ChatService.sln.DotSettings.user index a482b95..3175646 100644 --- a/ChatService.sln.DotSettings.user +++ b/ChatService.sln.DotSettings.user @@ -7,7 +7,8 @@ <SessionState ContinuousTestingMode="0" IsActive="True" Name="Session" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> </SessionState> - DefaultEndpointsProtocol=https;AccountName=naa137;AccountKey=p6VJPQjkAPm3/JxBIY1l7Yj9ewb4Fp2l+Aciwm969OgT57+70hT/jMKcyDIdIwIJaEijfqsxAOdf+AStE/MZDw==;EndpointSuffix=core.windows.net + DefaultEndpointsProtocol=https;AccountName=naa137;AccountKey=p6VJPQjkAPm3/JxBIY1l7Yj9ewb4Fp2l+Aciwm969OgT57+70hT/jMKcyDIdIwIJaEijfqsxAOdf+AStE/MZDw==;EndpointSuffix=core.windows.net + AccountEndpoint=https://chat-service.documents.azure.com:443/;AccountKey=bTPHlotBT0IYyYa4sFscBdFm3dzVpTexbtk8ygqhytijFYLpJAByf5CThrODbuDVvmTOcDio2ywEACDbCYlcOg==; From dd5f28cbf5f2e66a2bc26cfb40dd0e4c5fb42b96 Mon Sep 17 00:00:00 2001 From: nadimakk Date: Mon, 27 Mar 2023 20:24:33 +0200 Subject: [PATCH 24/45] structure and begin storage layer --- .../Controllers/ProfileControllerTests.cs | 2 +- ChatService.Web/Dtos/Conversation.cs | 10 ++ ChatService.Web/Dtos/Message.cs | 11 ++ ChatService.Web/Dtos/UserConversation.cs | 6 + .../Exceptions/ConversationExistsException.cs | 8 + .../UserConversationExistsException.cs | 8 + .../Services/IConversationService.cs | 5 + .../Storage/CosmosConversationStore.cs | 139 ++++++++++++++++++ ChatService.Web/Storage/CosmosMessageStore.cs | 28 ++++ ChatService.Web/Storage/CosmosProfileStore.cs | 4 +- .../Storage/Entities/ConversationEntity.cs | 10 ++ .../Storage/Entities/MessageEntity.cs | 11 ++ .../Storage/Entities/ProfileEntity.cs | 7 +- .../Entities/UserConversationEntity.cs | 6 + ChatService.Web/Storage/IConversationStore.cs | 9 ++ ChatService.Web/Storage/IMessageStore.cs | 9 ++ 16 files changed, 269 insertions(+), 4 deletions(-) create mode 100644 ChatService.Web/Dtos/Conversation.cs create mode 100644 ChatService.Web/Dtos/Message.cs create mode 100644 ChatService.Web/Dtos/UserConversation.cs create mode 100644 ChatService.Web/Exceptions/ConversationExistsException.cs create mode 100644 ChatService.Web/Exceptions/UserConversationExistsException.cs create mode 100644 ChatService.Web/Services/IConversationService.cs create mode 100644 ChatService.Web/Storage/CosmosConversationStore.cs create mode 100644 ChatService.Web/Storage/CosmosMessageStore.cs create mode 100644 ChatService.Web/Storage/Entities/ConversationEntity.cs create mode 100644 ChatService.Web/Storage/Entities/MessageEntity.cs create mode 100644 ChatService.Web/Storage/Entities/UserConversationEntity.cs create mode 100644 ChatService.Web/Storage/IConversationStore.cs create mode 100644 ChatService.Web/Storage/IMessageStore.cs diff --git a/ChatService.Web.Tests/Controllers/ProfileControllerTests.cs b/ChatService.Web.Tests/Controllers/ProfileControllerTests.cs index fa581ec..67246a7 100644 --- a/ChatService.Web.Tests/Controllers/ProfileControllerTests.cs +++ b/ChatService.Web.Tests/Controllers/ProfileControllerTests.cs @@ -13,7 +13,7 @@ namespace ChatService.Web.Tests.Controllers; public class ProfileControllerTests : IClassFixture> { - private readonly Mock _profileServiceMock = new(); + private readonly Mock _profileServiceMock = new(); private readonly HttpClient _httpClient; private readonly Profile _profile = new Profile("foobar", "Foo", "Bar", "123"); diff --git a/ChatService.Web/Dtos/Conversation.cs b/ChatService.Web/Dtos/Conversation.cs new file mode 100644 index 0000000..8b50972 --- /dev/null +++ b/ChatService.Web/Dtos/Conversation.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.Azure.Cosmos.Serialization.HybridRow; + +namespace ChatService.Web.Dtos; + +public record Conversation( + [Required] string id, + [Required] UnixDateTime lastModifiedTime, + [Required] List participants + ); \ 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..708d8fa --- /dev/null +++ b/ChatService.Web/Dtos/Message.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.Azure.Cosmos.Serialization.HybridRow; + +namespace ChatService.Web.Dtos; + +public record Message( + [Required] string id, + [Required] UnixDateTime UnixTime, + [Required] string senderUsername, + [Required] string text +); \ 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..89b1507 --- /dev/null +++ b/ChatService.Web/Dtos/UserConversation.cs @@ -0,0 +1,6 @@ +namespace ChatService.Web.Dtos; + +public record UserConversation( + string username, + string conversationId + ); \ 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/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/Services/IConversationService.cs b/ChatService.Web/Services/IConversationService.cs new file mode 100644 index 0000000..986989a --- /dev/null +++ b/ChatService.Web/Services/IConversationService.cs @@ -0,0 +1,5 @@ +namespace ChatService.Web.Services; + +public interface IConversationService +{ +} \ No newline at end of file diff --git a/ChatService.Web/Storage/CosmosConversationStore.cs b/ChatService.Web/Storage/CosmosConversationStore.cs new file mode 100644 index 0000000..4653f6d --- /dev/null +++ b/ChatService.Web/Storage/CosmosConversationStore.cs @@ -0,0 +1,139 @@ +using System.Net; +using ChatService.Web.Dtos; +using ChatService.Web.Exceptions; +using ChatService.Web.Storage.Entities; +using Microsoft.Azure.Cosmos; + +namespace ChatService.Web.Storage; + +public class CosmosConversationStore : IConversationStore +{ + private readonly CosmosClient _cosmosClient; + + public CosmosConversationStore(CosmosClient cosmosClient) + { + _cosmosClient = cosmosClient; + } + + private Container Container => _cosmosClient.GetDatabase("chatService").GetContainer("sharedContainer"); + + + public async Task CreateConversation(Conversation conversation) + { + if (conversation == null || + conversation.participants.Count < 2 || + string.IsNullOrWhiteSpace(conversation.id) + ) + { + throw new ArgumentException($"Invalid conversation {conversation}", nameof(conversation)); + } + + try + { + await Container.CreateItemAsync(ToEntity(conversation), new PartitionKey(conversation.id)); + } + catch (CosmosException e) + { + if (e.StatusCode == HttpStatusCode.Conflict) + { + throw new ConversationExistsException($"A conversation with id {conversation.id} already exists."); + } + throw; + } + } + + public async Task CreateUserConversation(UserConversation userConversation) + { + if (userConversation == null || + string.IsNullOrWhiteSpace(userConversation.username) || + string.IsNullOrWhiteSpace(userConversation.conversationId) + ) + { + throw new ArgumentException($"Invalid user conversation {userConversation}", nameof(userConversation)); + } + + try + { + await Container.CreateItemAsync(ToEntity(userConversation), new PartitionKey(userConversation.username)); + } + catch (CosmosException e) + { + if (e.StatusCode == HttpStatusCode.Conflict) + { + throw new UserConversationExistsException($"A user conversation with conversation ID {userConversation.conversationId} already exists."); + } + throw; + } + } + + public async Task DeleteConversation(string conversationId) + { + try + { + await Container.DeleteItemAsync( + id: conversationId, + partitionKey: new PartitionKey(conversationId)); + } + catch (CosmosException e) + { + if (e.StatusCode == HttpStatusCode.NotFound) + { + return; + } + throw; + } + } + + public async Task DeleteUserConversation(string username, string conversationId) + { + try + { + await Container.DeleteItemAsync( + id: conversationId, + partitionKey: new PartitionKey(username)); + } + catch (CosmosException e) + { + if (e.StatusCode == HttpStatusCode.NotFound) + { + return; + } + throw; + } + } + + private static ConversationEntity ToEntity(Conversation conversation) + { + return new ConversationEntity( + partitionKey: conversation.id, + id: conversation.id, + conversation.lastModifiedTime, + conversation.participants + ); + } + + private static Conversation ToConversation(ConversationEntity entity) + { + return new Conversation( + id: entity.id, + entity.lastModifiedTime, + entity.participants + ); + } + + private static UserConversationEntity ToEntity(UserConversation userConversation) + { + return new UserConversationEntity( + partitionKey: userConversation.username, + id: userConversation.conversationId + ); + } + + private static UserConversation ToUserConversation(UserConversationEntity entity) + { + return new UserConversation( + username: entity.partitionKey, + conversationId: entity.id + ); + } +} \ 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..b340b08 --- /dev/null +++ b/ChatService.Web/Storage/CosmosMessageStore.cs @@ -0,0 +1,28 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Storage.Entities; + +namespace ChatService.Web.Storage; + +public class CosmosMessageStore +{ + 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, + entity.UnixTime, + entity.senderUsername, + entity.text + ); + } +} \ No newline at end of file diff --git a/ChatService.Web/Storage/CosmosProfileStore.cs b/ChatService.Web/Storage/CosmosProfileStore.cs index 407e45c..3a7c94b 100644 --- a/ChatService.Web/Storage/CosmosProfileStore.cs +++ b/ChatService.Web/Storage/CosmosProfileStore.cs @@ -10,12 +10,12 @@ public class CosmosProfileStore : IProfileStore { private readonly CosmosClient _cosmosClient; - public CosmosProfileStore(CosmosClient cosmosClient, IImageStore imageStore) + public CosmosProfileStore(CosmosClient cosmosClient) { _cosmosClient = cosmosClient; } - private Container Container => _cosmosClient.GetDatabase("chatService").GetContainer("profiles"); + private Container Container => _cosmosClient.GetDatabase("chatService").GetContainer("sharedContainer"); public async Task AddProfile(Profile profile) { diff --git a/ChatService.Web/Storage/Entities/ConversationEntity.cs b/ChatService.Web/Storage/Entities/ConversationEntity.cs new file mode 100644 index 0000000..c9d1589 --- /dev/null +++ b/ChatService.Web/Storage/Entities/ConversationEntity.cs @@ -0,0 +1,10 @@ +using Microsoft.Azure.Cosmos.Serialization.HybridRow; + +namespace ChatService.Web.Storage.Entities; + +public record ConversationEntity( + string partitionKey, + string id, + UnixDateTime lastModifiedTime, + List participants + ); \ 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..740cf3b --- /dev/null +++ b/ChatService.Web/Storage/Entities/MessageEntity.cs @@ -0,0 +1,11 @@ +using Microsoft.Azure.Cosmos.Serialization.HybridRow; + +namespace ChatService.Web.Storage.Entities; + +public record MessageEntity( + string partitionKey, + string id, + UnixDateTime 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 index abc1d1a..afb63cb 100644 --- a/ChatService.Web/Storage/Entities/ProfileEntity.cs +++ b/ChatService.Web/Storage/Entities/ProfileEntity.cs @@ -1,3 +1,8 @@ namespace ChatService.Web.Storage.Entities; -public record ProfileEntity(string partitionKey, string id, string firstName, string lastName, string profilePictureId); +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..b6c660e --- /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 + ); \ No newline at end of file diff --git a/ChatService.Web/Storage/IConversationStore.cs b/ChatService.Web/Storage/IConversationStore.cs new file mode 100644 index 0000000..1680fb2 --- /dev/null +++ b/ChatService.Web/Storage/IConversationStore.cs @@ -0,0 +1,9 @@ +using ChatService.Web.Dtos; + +namespace ChatService.Web.Storage; + +public interface IConversationStore +{ + Task CreateConversation(Conversation conversation); + Task CreateUserConversation(UserConversation userConversation); +} \ 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..5018d1f --- /dev/null +++ b/ChatService.Web/Storage/IMessageStore.cs @@ -0,0 +1,9 @@ +using ChatService.Web.Dtos; + +namespace ChatService.Web.Storage; + +public interface IMessageStore +{ + Task AddMessage(Message message); + Task GetMessages(string conversationId, string continuationToken, int limit); +} \ No newline at end of file From c0b3947036fae28dff39cd5382003c21c09e45dd Mon Sep 17 00:00:00 2001 From: Ali Date: Tue, 28 Mar 2023 18:35:28 +0300 Subject: [PATCH 25/45] Modify database entities & update ComosConversationStore --- .../CosmosConversationStoreTests.cs | 80 +++++++++++ .../Controllers/ProfileControllerTests.cs | 13 ++ .../Services/ProfileServiceTests.cs | 7 + .../Controllers/ProfileController.cs | 2 +- ChatService.Web/Dtos/UserConversation.cs | 12 +- ChatService.Web/Enums/OrderBy.cs | 7 + .../Exceptions/InvalidUsernameException.cs | 8 ++ .../UserConversationNotFoundException.cs | 8 ++ ChatService.Web/Program.cs | 2 + ChatService.Web/Services/ProfileService.cs | 5 + .../Storage/CosmosConversationStore.cs | 126 ++++++++++-------- .../Storage/Entities/ConversationEntity.cs | 10 -- .../Entities/UserConversationEntity.cs | 5 +- ChatService.Web/Storage/IConversationStore.cs | 7 +- ChatService.sln.DotSettings.user | 7 +- 15 files changed, 226 insertions(+), 73 deletions(-) create mode 100644 ChatService.Web.IntegrationTests/CosmosConversationStoreTests.cs create mode 100644 ChatService.Web/Enums/OrderBy.cs create mode 100644 ChatService.Web/Exceptions/InvalidUsernameException.cs create mode 100644 ChatService.Web/Exceptions/UserConversationNotFoundException.cs delete mode 100644 ChatService.Web/Storage/Entities/ConversationEntity.cs diff --git a/ChatService.Web.IntegrationTests/CosmosConversationStoreTests.cs b/ChatService.Web.IntegrationTests/CosmosConversationStoreTests.cs new file mode 100644 index 0000000..df4adcc --- /dev/null +++ b/ChatService.Web.IntegrationTests/CosmosConversationStoreTests.cs @@ -0,0 +1,80 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Enums; +using ChatService.Web.Storage; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace ChatService.Web.IntegrationTests; + +public class CosmosConversationStoreTests : IClassFixture>, IAsyncLifetime +{ + private readonly IConversationStore _store; + + private readonly UserConversation _userConversation = new UserConversation + { + username = Guid.NewGuid().ToString(), + conversationId = Guid.NewGuid().ToString(), + lastModifiedTime = 1 + }; + + public CosmosConversationStoreTests(WebApplicationFactory factory) + { + _store = factory.Services.GetRequiredService(); + } + + [Theory] + [InlineData(OrderBy.ASC)] + [InlineData(OrderBy.DESC)] + public async Task GetUserConversations_Ordered_Successful(OrderBy orderBy) + { + UserConversation userConversationSecond = new UserConversation + { + username = _userConversation.username, + conversationId = Guid.NewGuid().ToString(), + lastModifiedTime = 2 + }; + + await _store.CreateUserConversation(_userConversation); + await _store.CreateUserConversation(userConversationSecond); + + List userConversationsExpected = new(); + userConversationsExpected.Add(_userConversation); + userConversationsExpected.Add(userConversationSecond); + + var response = await _store.GetUserConversations(_userConversation.username, 1, orderBy, null, DateTimeOffset.UtcNow.ToUnixTimeSeconds()); + + if (orderBy == OrderBy.ASC) + { + Assert.Equal(response.UserConversations, userConversationsExpected); + } + else + { + userConversationsExpected.Reverse(); + Assert.Equal(response.UserConversations, userConversationsExpected); + } + + await _store.DeleteUserConversation(userConversationSecond.username, userConversationSecond.conversationId); + } + + [Theory] + [InlineData("", 1)] + [InlineData(" ", 1)] + [InlineData(null, 0)] + [InlineData("username", 0)] + [InlineData("username", -1)] + public async Task GetUserConversations_InvalidArguments(string username, int limit) + { + await Assert.ThrowsAsync( + () => _store.GetUserConversations(username, limit, OrderBy.ASC, null, 1)); + } + + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + await _store.DeleteUserConversation(_userConversation.username, _userConversation.conversationId); + } +} \ No newline at end of file diff --git a/ChatService.Web.Tests/Controllers/ProfileControllerTests.cs b/ChatService.Web.Tests/Controllers/ProfileControllerTests.cs index 67246a7..f8a1fdc 100644 --- a/ChatService.Web.Tests/Controllers/ProfileControllerTests.cs +++ b/ChatService.Web.Tests/Controllers/ProfileControllerTests.cs @@ -109,7 +109,20 @@ public async Task PostProfile_InvalidArguments(string username, string firstName Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } + + [Fact] + public async Task PostProfile_InvalidUsername() + { + Profile profile = new("username_with_underscore", "firstName", "lastName", "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("/Profile", profile); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + [Fact] public async Task ProfileProfile_ProfilePictureNotFound() { diff --git a/ChatService.Web.Tests/Services/ProfileServiceTests.cs b/ChatService.Web.Tests/Services/ProfileServiceTests.cs index 0d8809e..6cd73e2 100644 --- a/ChatService.Web.Tests/Services/ProfileServiceTests.cs +++ b/ChatService.Web.Tests/Services/ProfileServiceTests.cs @@ -89,6 +89,13 @@ public async Task AddNewProfile_UsernameTaken() await Assert.ThrowsAsync( async () => await _profileService.AddProfile(_profile)); } + [Fact] + public async Task AddNewProfile_InvalidUsername() + { + Profile profile = new("username_with_underscore", "firstName", "lastName", "profilePictureId"); + await Assert.ThrowsAsync( async () => await _profileService.AddProfile(profile)); + } + [Fact] public async Task AddNewProfile_ProfilePictureNotFound() { diff --git a/ChatService.Web/Controllers/ProfileController.cs b/ChatService.Web/Controllers/ProfileController.cs index af32490..8e38bab 100644 --- a/ChatService.Web/Controllers/ProfileController.cs +++ b/ChatService.Web/Controllers/ProfileController.cs @@ -36,7 +36,7 @@ public async Task> PostProfile(Profile profile) await _profileService.AddProfile(profile); return CreatedAtAction(nameof(GetProfile), new { username = profile.username }, profile); } - catch (Exception e) when (e is ArgumentException || e is ImageNotFoundException) + catch (Exception e) when (e is ArgumentException || e is ImageNotFoundException || e is InvalidUsernameException) { return BadRequest(e.Message); } diff --git a/ChatService.Web/Dtos/UserConversation.cs b/ChatService.Web/Dtos/UserConversation.cs index 89b1507..3b8192b 100644 --- a/ChatService.Web/Dtos/UserConversation.cs +++ b/ChatService.Web/Dtos/UserConversation.cs @@ -1,6 +1,10 @@ +using Microsoft.Azure.Cosmos.Serialization.HybridRow; + namespace ChatService.Web.Dtos; -public record UserConversation( - string username, - string conversationId - ); \ No newline at end of file +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/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/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/Program.cs b/ChatService.Web/Program.cs index 47b001d..9ba792d 100644 --- a/ChatService.Web/Program.cs +++ b/ChatService.Web/Program.cs @@ -14,6 +14,8 @@ // Add services to the container. builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => { var cosmosOptions = sp.GetRequiredService>(); diff --git a/ChatService.Web/Services/ProfileService.cs b/ChatService.Web/Services/ProfileService.cs index e1fda60..2eb42f9 100644 --- a/ChatService.Web/Services/ProfileService.cs +++ b/ChatService.Web/Services/ProfileService.cs @@ -32,6 +32,11 @@ public async Task AddProfile(Profile profile) throw new ArgumentException($"Invalid profile {profile}", nameof(profile)); } + if (profile.username.Contains('_')) + { + throw new InvalidUsernameException($"Username {profile.username} is invalid. Usernames cannot have an underscore."); + } + bool imageExists = await _imageStore.ImageExists(profile.profilePictureId); if (!imageExists) { diff --git a/ChatService.Web/Storage/CosmosConversationStore.cs b/ChatService.Web/Storage/CosmosConversationStore.cs index 4653f6d..9cdc46f 100644 --- a/ChatService.Web/Storage/CosmosConversationStore.cs +++ b/ChatService.Web/Storage/CosmosConversationStore.cs @@ -1,8 +1,10 @@ using System.Net; using ChatService.Web.Dtos; +using ChatService.Web.Enums; using ChatService.Web.Exceptions; using ChatService.Web.Storage.Entities; using Microsoft.Azure.Cosmos; +using Microsoft.Azure.Cosmos.Linq; namespace ChatService.Web.Storage; @@ -17,73 +19,104 @@ public CosmosConversationStore(CosmosClient cosmosClient) private Container Container => _cosmosClient.GetDatabase("chatService").GetContainer("sharedContainer"); - - public async Task CreateConversation(Conversation conversation) + public async Task CreateUserConversation(UserConversation userConversation) { - if (conversation == null || - conversation.participants.Count < 2 || - string.IsNullOrWhiteSpace(conversation.id) - ) + if (userConversation == null || + string.IsNullOrWhiteSpace(userConversation.username) || + string.IsNullOrWhiteSpace(userConversation.conversationId) + ) { - throw new ArgumentException($"Invalid conversation {conversation}", nameof(conversation)); + throw new ArgumentException($"Invalid user conversation {userConversation}", nameof(userConversation)); } try { - await Container.CreateItemAsync(ToEntity(conversation), new PartitionKey(conversation.id)); + await Container.CreateItemAsync(ToEntity(userConversation), new PartitionKey(userConversation.username)); } catch (CosmosException e) { if (e.StatusCode == HttpStatusCode.Conflict) { - throw new ConversationExistsException($"A conversation with id {conversation.id} already exists."); + throw new UserConversationExistsException($"A user conversation with conversation ID {userConversation.conversationId} already exists."); } throw; } } - public async Task CreateUserConversation(UserConversation userConversation) + public async Task GetUserConversation(string username, string conversationId) { - if (userConversation == null || - string.IsNullOrWhiteSpace(userConversation.username) || - string.IsNullOrWhiteSpace(userConversation.conversationId) - ) + if (string.IsNullOrWhiteSpace(username)) { - throw new ArgumentException($"Invalid user conversation {userConversation}", nameof(userConversation)); + throw new ArgumentException($"Invalid username {username}"); } + if (string.IsNullOrWhiteSpace(conversationId)) + { + throw new ArgumentException($"Invalid conversationId {conversationId}"); + } try { - await Container.CreateItemAsync(ToEntity(userConversation), new PartitionKey(userConversation.username)); + 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.Conflict) + if (e.StatusCode == HttpStatusCode.NotFound) { - throw new UserConversationExistsException($"A user conversation with conversation ID {userConversation.conversationId} already exists."); + throw new UserConversationNotFoundException($"A UserConversation with conversationId {conversationId} was not found."); } throw; } } - - public async Task DeleteConversation(string conversationId) + + public async Task<(List UserConversations, string NextContinuationToken)> GetUserConversations + (string username, int limit, OrderBy order, string? continuationToken, long lastSeenConversationTime) { - try + if (string.IsNullOrWhiteSpace(username)) { - await Container.DeleteItemAsync( - id: conversationId, - partitionKey: new PartitionKey(conversationId)); + throw new ArgumentException("Username cannot be null or empty."); } - catch (CosmosException e) + if (limit <= 0) { - if (e.StatusCode == HttpStatusCode.NotFound) - { - return; - } - throw; + throw new ArgumentException($"Invalid limit {limit}"); + } + + var query = Container.GetItemLinqQueryable(true, continuationToken) + .Where(e => e.partitionKey == username && e.lastModifiedTime > lastSeenConversationTime) + .Take(limit); + + if (order == OrderBy.ASC) + { + query = query.OrderBy(e => e.lastModifiedTime); + } + else + { + query = query.OrderByDescending(e => e.lastModifiedTime); + } + + var iterator = query.ToFeedIterator(); + + List userConversations = new (); + string nextContinuationToken = ""; + + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + var receivedUserConversations = response.Select(ToUserConversation); + userConversations.AddRange(receivedUserConversations); + nextContinuationToken = response.ContinuationToken; } + + return (userConversations, nextContinuationToken); } - + public async Task DeleteUserConversation(string username, string conversationId) { try @@ -101,39 +134,22 @@ await Container.DeleteItemAsync( throw; } } - - private static ConversationEntity ToEntity(Conversation conversation) - { - return new ConversationEntity( - partitionKey: conversation.id, - id: conversation.id, - conversation.lastModifiedTime, - conversation.participants - ); - } - private static Conversation ToConversation(ConversationEntity entity) - { - return new Conversation( - id: entity.id, - entity.lastModifiedTime, - entity.participants - ); - } - private static UserConversationEntity ToEntity(UserConversation userConversation) { return new UserConversationEntity( partitionKey: userConversation.username, - id: userConversation.conversationId + id: userConversation.conversationId, + userConversation.lastModifiedTime ); } private static UserConversation ToUserConversation(UserConversationEntity entity) { - return new UserConversation( - username: entity.partitionKey, - conversationId: entity.id - ); + return new UserConversation { + username = entity.partitionKey, + conversationId = entity.id, + lastModifiedTime = entity.lastModifiedTime + }; } } \ No newline at end of file diff --git a/ChatService.Web/Storage/Entities/ConversationEntity.cs b/ChatService.Web/Storage/Entities/ConversationEntity.cs deleted file mode 100644 index c9d1589..0000000 --- a/ChatService.Web/Storage/Entities/ConversationEntity.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Microsoft.Azure.Cosmos.Serialization.HybridRow; - -namespace ChatService.Web.Storage.Entities; - -public record ConversationEntity( - string partitionKey, - string id, - UnixDateTime lastModifiedTime, - List participants - ); \ No newline at end of file diff --git a/ChatService.Web/Storage/Entities/UserConversationEntity.cs b/ChatService.Web/Storage/Entities/UserConversationEntity.cs index b6c660e..4fc3560 100644 --- a/ChatService.Web/Storage/Entities/UserConversationEntity.cs +++ b/ChatService.Web/Storage/Entities/UserConversationEntity.cs @@ -1,6 +1,9 @@ +using Microsoft.Azure.Cosmos.Serialization.HybridRow; + namespace ChatService.Web.Storage.Entities; public record UserConversationEntity( string partitionKey, - string id + string id, + long lastModifiedTime ); \ No newline at end of file diff --git a/ChatService.Web/Storage/IConversationStore.cs b/ChatService.Web/Storage/IConversationStore.cs index 1680fb2..531e225 100644 --- a/ChatService.Web/Storage/IConversationStore.cs +++ b/ChatService.Web/Storage/IConversationStore.cs @@ -1,9 +1,14 @@ using ChatService.Web.Dtos; +using ChatService.Web.Enums; +using Microsoft.Azure.Cosmos.Serialization.HybridRow; namespace ChatService.Web.Storage; public interface IConversationStore { - Task CreateConversation(Conversation conversation); Task CreateUserConversation(UserConversation userConversation); + Task GetUserConversation(string username, string conversationId); + Task<(List UserConversations, string NextContinuationToken)> GetUserConversations(string username, int limit, OrderBy order, + string? continuationToken, long lastSeenConversationTime); + Task DeleteUserConversation(string username, string conversationId); } \ No newline at end of file diff --git a/ChatService.sln.DotSettings.user b/ChatService.sln.DotSettings.user index 3175646..e79add3 100644 --- a/ChatService.sln.DotSettings.user +++ b/ChatService.sln.DotSettings.user @@ -4,7 +4,12 @@ On On C:\Users\nadim\AppData\Local\JetBrains\Rider2022.3\resharper-host\temp\Rider\vAny\CoverageData\_ChatService.34854384\Snapshot\snapshot.utdcvr - <SessionState ContinuousTestingMode="0" IsActive="True" Name="Session" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <SessionState ContinuousTestingMode="0" IsActive="True" Name="GetUserConversations_Ordered_Successful" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <TestAncestor> + <TestId>xUnit::A7808CB8-B561-4939-9B4E-8AC0FD786DC3::net6.0::ChatService.Web.IntegrationTests.CosmosConversationStoreTests</TestId> + </TestAncestor> +</SessionState> + <SessionState ContinuousTestingMode="0" Name="Session" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> </SessionState> DefaultEndpointsProtocol=https;AccountName=naa137;AccountKey=p6VJPQjkAPm3/JxBIY1l7Yj9ewb4Fp2l+Aciwm969OgT57+70hT/jMKcyDIdIwIJaEijfqsxAOdf+AStE/MZDw==;EndpointSuffix=core.windows.net From d6152f455c57ad925185a038d67967c5d9a50d16 Mon Sep 17 00:00:00 2001 From: nadimakk Date: Tue, 28 Mar 2023 21:53:26 +0200 Subject: [PATCH 26/45] complete conversation store and tests --- .../CosmosConversationStoreTests.cs | 181 +++++++++++++++++- .../Storage/CosmosConversationStore.cs | 27 +-- ChatService.sln.DotSettings.user | 6 +- 3 files changed, 193 insertions(+), 21 deletions(-) diff --git a/ChatService.Web.IntegrationTests/CosmosConversationStoreTests.cs b/ChatService.Web.IntegrationTests/CosmosConversationStoreTests.cs index df4adcc..b563717 100644 --- a/ChatService.Web.IntegrationTests/CosmosConversationStoreTests.cs +++ b/ChatService.Web.IntegrationTests/CosmosConversationStoreTests.cs @@ -1,5 +1,6 @@ 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; @@ -10,28 +11,122 @@ public class CosmosConversationStoreTests : IClassFixture factory) { _store = factory.Services.GetRequiredService(); } + + [Fact] + public async Task CreateUserConversation_Successful() + { + await _store.CreateUserConversation(_userConversation); + + Assert.Equal(_userConversation, await _store.GetUserConversation(_userConversation.username, _userConversation.conversationId)); + } + + [Theory] + [InlineData(null, "dummy_conversationId")] + [InlineData("", "dummy_conversationId")] + [InlineData(" ", "dummy_conversationId")] + [InlineData("foobar", null)] + [InlineData("foobar", "")] + [InlineData("foobar", " ")] + public async Task CreateUserConversation_InvalidArguments(string username, string conversationId) + { + UserConversation userConversation = new() + { + username = username, + conversationId = conversationId + }; + + await Assert.ThrowsAsync( + () => _store.CreateUserConversation(userConversation)); + } + + [Fact] + public async Task CreateUserConversation_ConversationAlreadyExists() + { + await _store.CreateUserConversation(_userConversation); + + await Assert.ThrowsAsync( + () => _store.CreateUserConversation(_userConversation)); + } + + [Theory] + [InlineData(null, "dummy_conversationId")] + [InlineData("", "dummy_conversationId")] + [InlineData(" ", "dummy_conversationId")] + [InlineData("foobar", null)] + [InlineData("foobar", "")] + [InlineData("foobar", " ")] + public async Task GetUserConversation_InvalidArguments(string username, string conversationId) + { + await Assert.ThrowsAsync( + () => _store.GetUserConversation(username, conversationId)); + } + + [Fact] + public async Task GetUserConversation_ConversationNotFound() + { + await Assert.ThrowsAsync( + () => _store.GetUserConversation(_userConversation.username, _userConversation.conversationId)); + } + [Fact] + public async Task GetUserConversations_Limit() + { + await AddMultipleUserConversations(_userConversation1, _userConversation2, _userConversation3); + + var response = await _store.GetUserConversations(_userConversation.username, 1, OrderBy.ASC, null, 1); + Assert.Equal(1, response.UserConversations.Count); + + response = await _store.GetUserConversations(_userConversation.username, 2, OrderBy.ASC, null, 1); + Assert.Equal(2, response.UserConversations.Count); + + response = await _store.GetUserConversations(_userConversation.username, 3, OrderBy.ASC, null, 1); + Assert.Equal(3, response.UserConversations.Count); + + await DeleteMultipleUserConversations(_userConversation1, _userConversation2, _userConversation3); + } + [Theory] [InlineData(OrderBy.ASC)] [InlineData(OrderBy.DESC)] - public async Task GetUserConversations_Ordered_Successful(OrderBy orderBy) + public async Task GetUserConversations_OrderBy(OrderBy orderBy) { UserConversation userConversationSecond = new UserConversation { username = _userConversation.username, conversationId = Guid.NewGuid().ToString(), - lastModifiedTime = 2 + lastModifiedTime = _userConversation.lastModifiedTime }; await _store.CreateUserConversation(_userConversation); @@ -41,21 +136,77 @@ public async Task GetUserConversations_Ordered_Successful(OrderBy orderBy) userConversationsExpected.Add(_userConversation); userConversationsExpected.Add(userConversationSecond); - var response = await _store.GetUserConversations(_userConversation.username, 1, orderBy, null, DateTimeOffset.UtcNow.ToUnixTimeSeconds()); + var response = await _store.GetUserConversations(_userConversation.username, 2, orderBy, null, 1); if (orderBy == OrderBy.ASC) { - Assert.Equal(response.UserConversations, userConversationsExpected); + Assert.Equal(userConversationsExpected, response.UserConversations); } else { userConversationsExpected.Reverse(); - Assert.Equal(response.UserConversations, userConversationsExpected); + Assert.Equal(userConversationsExpected, response.UserConversations); } await _store.DeleteUserConversation(userConversationSecond.username, userConversationSecond.conversationId); } + [Fact] + public async Task GetUserConversations_ContinuationTokenValidity() + { + await _store.CreateUserConversation(_userConversation1); + await _store.CreateUserConversation(_userConversation2); + await _store.CreateUserConversation(_userConversation3); + + var response = await _store.GetUserConversations(_userConversation.username, 1, OrderBy.ASC, null, 1); + + Assert.Equal(_userConversation1, response.UserConversations.ElementAt(0)); + + var nextContinuation = response.NextContinuationToken; + Assert.NotNull(nextContinuation); + + response = await _store.GetUserConversations(_userConversation.username, 1, OrderBy.ASC, nextContinuation, 1); + Assert.Equal(_userConversation2, response.UserConversations.ElementAt(0)); + + nextContinuation = response.NextContinuationToken; + Assert.NotNull(nextContinuation); + + response = await _store.GetUserConversations(_userConversation.username, 1, OrderBy.ASC, nextContinuation, 1); + Assert.Equal(_userConversation3, response.UserConversations.ElementAt(0)); + + nextContinuation = response.NextContinuationToken; + Assert.Null(nextContinuation); + + await _store.DeleteUserConversation(_userConversation.username, _userConversation1.conversationId); + await _store.DeleteUserConversation(_userConversation.username, _userConversation2.conversationId); + await _store.DeleteUserConversation(_userConversation.username, _userConversation3.conversationId); + } + + [Fact] + public async Task GetUserConversations_LastSeenConversationTime() + { + await AddMultipleUserConversations(_userConversation1, _userConversation2, _userConversation3, _userConversation); + + List UnixTime50Expected = new List { _userConversation1, _userConversation2, _userConversation3, _userConversation }; + List UnixTime150Expected = new List { _userConversation2, _userConversation3, _userConversation }; + List UnixTime250Expected = new List { _userConversation3, _userConversation }; + List UnixTime350Expected = new List { _userConversation }; + + var response = await _store.GetUserConversations(_userConversation.username, 10, OrderBy.ASC, null, 50); + Assert.Equal(UnixTime50Expected, response.UserConversations); + + response = await _store.GetUserConversations(_userConversation.username, 10, OrderBy.ASC, null, 150); + Assert.Equal(UnixTime150Expected, response.UserConversations); + + response = await _store.GetUserConversations(_userConversation.username, 10, OrderBy.ASC, null, 250); + Assert.Equal(UnixTime250Expected, response.UserConversations); + + response = await _store.GetUserConversations(_userConversation.username, 10, OrderBy.ASC, null, 350); + Assert.Equal(UnixTime350Expected, response.UserConversations); + + await DeleteMultipleUserConversations(_userConversation1, _userConversation2, _userConversation3); + } + [Theory] [InlineData("", 1)] [InlineData(" ", 1)] @@ -68,6 +219,22 @@ await Assert.ThrowsAsync( () => _store.GetUserConversations(username, limit, OrderBy.ASC, null, 1)); } + private async Task AddMultipleUserConversations(params UserConversation[] userConversations) + { + foreach (UserConversation userConversation in userConversations) + { + await _store.CreateUserConversation(userConversation); + } + } + + private async Task DeleteMultipleUserConversations(params UserConversation[] userConversations) + { + foreach (UserConversation userConversation in userConversations) + { + await _store.DeleteUserConversation(userConversation.username, userConversation.conversationId); + } + } + public Task InitializeAsync() { return Task.CompletedTask; diff --git a/ChatService.Web/Storage/CosmosConversationStore.cs b/ChatService.Web/Storage/CosmosConversationStore.cs index 9cdc46f..2816343 100644 --- a/ChatService.Web/Storage/CosmosConversationStore.cs +++ b/ChatService.Web/Storage/CosmosConversationStore.cs @@ -87,11 +87,17 @@ public async Task GetUserConversation(string username, string { throw new ArgumentException($"Invalid limit {limit}"); } + + List userConversations = new (); + string nextContinuationToken = null; + + QueryRequestOptions options = new QueryRequestOptions(); + options.MaxItemCount = limit; - var query = Container.GetItemLinqQueryable(true, continuationToken) - .Where(e => e.partitionKey == username && e.lastModifiedTime > lastSeenConversationTime) - .Take(limit); - + IQueryable query = Container + .GetItemLinqQueryable(false, continuationToken, options) + .Where(e => e.partitionKey == username && e.lastModifiedTime > lastSeenConversationTime); + if (order == OrderBy.ASC) { query = query.OrderBy(e => e.lastModifiedTime); @@ -101,18 +107,15 @@ public async Task GetUserConversation(string username, string query = query.OrderByDescending(e => e.lastModifiedTime); } - var iterator = query.ToFeedIterator(); - - List userConversations = new (); - string nextContinuationToken = ""; - - while (iterator.HasMoreResults) + using (FeedIterator iterator = query.ToFeedIterator()) { - var response = await iterator.ReadNextAsync(); + FeedResponse response = await iterator.ReadNextAsync(); var receivedUserConversations = response.Select(ToUserConversation); + userConversations.AddRange(receivedUserConversations); + nextContinuationToken = response.ContinuationToken; - } + }; return (userConversations, nextContinuationToken); } diff --git a/ChatService.sln.DotSettings.user b/ChatService.sln.DotSettings.user index e79add3..fd7f47e 100644 --- a/ChatService.sln.DotSettings.user +++ b/ChatService.sln.DotSettings.user @@ -12,9 +12,11 @@ <SessionState ContinuousTestingMode="0" Name="Session" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> </SessionState> - DefaultEndpointsProtocol=https;AccountName=naa137;AccountKey=p6VJPQjkAPm3/JxBIY1l7Yj9ewb4Fp2l+Aciwm969OgT57+70hT/jMKcyDIdIwIJaEijfqsxAOdf+AStE/MZDw==;EndpointSuffix=core.windows.net - AccountEndpoint=https://chat-service.documents.azure.com:443/;AccountKey=bTPHlotBT0IYyYa4sFscBdFm3dzVpTexbtk8ygqhytijFYLpJAByf5CThrODbuDVvmTOcDio2ywEACDbCYlcOg==; + DefaultEndpointsProtocol=https;AccountName=naa137;AccountKey=p6VJPQjkAPm3/JxBIY1l7Yj9ewb4Fp2l+Aciwm969OgT57+70hT/jMKcyDIdIwIJaEijfqsxAOdf+AStE/MZDw==;EndpointSuffix=core.windows.net + + + AccountEndpoint=https://chat-service.documents.azure.com:443/;AccountKey=bTPHlotBT0IYyYa4sFscBdFm3dzVpTexbtk8ygqhytijFYLpJAByf5CThrODbuDVvmTOcDio2ywEACDbCYlcOg==; From 233bf111e49c4a9200df462dfe476f4392c23f18 Mon Sep 17 00:00:00 2001 From: Ali Date: Wed, 29 Mar 2023 20:02:19 +0300 Subject: [PATCH 27/45] Implement CosmosMessageStore --- .../CosmosMessageStoreTests.cs | 217 ++++++++++++++++++ ChatService.Web/Dtos/Message.cs | 13 +- .../Exceptions/MessageExistsException.cs | 8 + .../Exceptions/MessageNotFoundException.cs | 8 + ChatService.Web/Program.cs | 1 + ChatService.Web/Storage/CosmosMessageStore.cs | 151 +++++++++++- .../Storage/Entities/MessageEntity.cs | 2 +- ChatService.Web/Storage/IConversationStore.cs | 4 +- ChatService.Web/Storage/IMessageStore.cs | 10 +- ChatService.sln.DotSettings.user | 19 +- 10 files changed, 411 insertions(+), 22 deletions(-) create mode 100644 ChatService.Web.IntegrationTests/CosmosMessageStoreTests.cs create mode 100644 ChatService.Web/Exceptions/MessageExistsException.cs create mode 100644 ChatService.Web/Exceptions/MessageNotFoundException.cs diff --git a/ChatService.Web.IntegrationTests/CosmosMessageStoreTests.cs b/ChatService.Web.IntegrationTests/CosmosMessageStoreTests.cs new file mode 100644 index 0000000..5bd37b3 --- /dev/null +++ b/ChatService.Web.IntegrationTests/CosmosMessageStoreTests.cs @@ -0,0 +1,217 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Enums; +using ChatService.Web.Exceptions; +using ChatService.Web.Storage; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace ChatService.Web.IntegrationTests; + +public class CosmosMessageStoreTests : IClassFixture>, IAsyncLifetime +{ + private readonly IMessageStore _store; + + private readonly string _conversationId = Guid.NewGuid().ToString(); + + private readonly Message _message1 = new Message + { + id = Guid.NewGuid().ToString(), + unixTime = 100, + senderUsername = Guid.NewGuid().ToString(), + text = "text of _message1" + }; + + private readonly Message _message2 = new Message + { + id = Guid.NewGuid().ToString(), + unixTime = 200, + senderUsername = Guid.NewGuid().ToString(), + text = "text of _message2" + }; + + private readonly Message _message3 = new Message + { + id = Guid.NewGuid().ToString(), + unixTime = 300, + senderUsername = Guid.NewGuid().ToString(), + text = "text of _message3" + }; + + public CosmosMessageStoreTests(WebApplicationFactory factory) + { + _store = factory.Services.GetRequiredService(); + } + + [Fact] + public async Task AddMessage_Successful() + { + await _store.AddMessage(_conversationId, _message1); + + Assert.Equal(_message1, await _store.GetMessage(_conversationId, _message1.id)); + } + + [Theory] + [InlineData(null, "senderUsername", "text")] + [InlineData("", "senderUsername", "text")] + [InlineData(" ", "senderUsername", "text")] + [InlineData("id", null, "text")] + [InlineData("id", "", "text")] + [InlineData("id", " ", "text")] + [InlineData("id", "senderUsername", null)] + [InlineData("id", "senderUsername", "")] + [InlineData("id", "senderUsername", " ")] + public async Task AddMessage_InvalidArguments(string id, string senderUsername, string text) + { + Message message = new Message + { + id = id, + unixTime = 100, + senderUsername = senderUsername, + text = text + }; + + await Assert.ThrowsAsync(() => _store.AddMessage(_conversationId, message)); + } + + [Fact] + public async Task AddMessage_MessageAlreadyExists() + { + await _store.AddMessage(_conversationId, _message1); + + await Assert.ThrowsAsync(() => _store.AddMessage(_conversationId, _message1)); + } + + [Theory] + [InlineData(null, "messageId")] + [InlineData("", "messageId")] + [InlineData(" ", "messageId")] + [InlineData("conversationId", null)] + [InlineData("conversationId", "")] + [InlineData("conversationId", " ")] + public async Task GetMessage_InvalidArguments(string conversationId, string messageId) + { + await Assert.ThrowsAsync(() => _store.GetMessage(conversationId, messageId)); + } + + [Fact] + public async Task GetMessage_MessageNotFound() + { + await Assert.ThrowsAsync(() => _store.GetMessage(_conversationId, _message1.id)); + } + + [Fact] + public async Task GetMessages_Limit() + { + await AddMultipleMessages(_conversationId, _message1, _message2, _message3); + + var response = await _store.GetMessages( + _conversationId, 1, OrderBy.ASC, null, 1); + Assert.Equal(1, response.Messages.Count); + + response = await _store.GetMessages(_conversationId, 2, OrderBy.ASC, null, 1); + Assert.Equal(2, response.Messages.Count); + + response = await _store.GetMessages(_conversationId, 3, OrderBy.ASC, null, 1); + Assert.Equal(3, response.Messages.Count); + + await DeleteMultipleMessages(_conversationId, _message1, _message2, _message3); + } + + [Theory] + [InlineData(OrderBy.ASC)] + [InlineData(OrderBy.DESC)] + public async Task GetMessages_OrderBy(OrderBy orderBy) + { + await AddMultipleMessages(_conversationId, _message1, _message2); + + List messagesExpected = new(); + messagesExpected.Add(_message1); + messagesExpected.Add(_message2); + + var response = await _store.GetMessages( + _conversationId, 10, orderBy, null, 1); + + if (orderBy == OrderBy.ASC) + { + Assert.Equal(messagesExpected, response.Messages); + } + else + { + messagesExpected.Reverse(); + Assert.Equal(messagesExpected, response.Messages); + } + + await _store.DeleteMessage(_conversationId, _message2.id); + } + + [Fact] + public async Task GetMessages_ContinuationTokenValidity() + { + await AddMultipleMessages(_conversationId, _message1, _message2, _message3); + + var response = await _store.GetMessages( + _conversationId, 1, OrderBy.ASC, null, 1); + Assert.Equal(_message1, response.Messages.ElementAt(0)); + Assert.NotNull(response.NextContinuationToken); + + response = await _store.GetMessages( + _conversationId, 1, OrderBy.ASC, response.NextContinuationToken, 1); + Assert.Equal(_message2, response.Messages.ElementAt(0)); + Assert.NotNull(response.NextContinuationToken); + + response = await _store.GetMessages( + _conversationId, 1, OrderBy.ASC, response.NextContinuationToken, 1); + Assert.Equal(_message3, response.Messages.ElementAt(0)); + Assert.Null(response.NextContinuationToken); + + await DeleteMultipleMessages(_conversationId, _message2, _message3); + } + + [Theory] + [InlineData(0)] + [InlineData(100)] + [InlineData(200)] + [InlineData(300)] + public async Task GetMessages_LastSeenMessageTime(long lastSeenMessageTime) + { + await AddMultipleMessages(_conversationId, _message1, _message2, _message3); + + List messagesExpected = new(); + if(_message1.unixTime > lastSeenMessageTime) messagesExpected.Add(_message1); + if(_message2.unixTime > lastSeenMessageTime) messagesExpected.Add(_message2); + if(_message3.unixTime > lastSeenMessageTime) messagesExpected.Add(_message3); + + var response = await _store.GetMessages( + _conversationId, 10, OrderBy.ASC, null, lastSeenMessageTime); + + Assert.Equal(messagesExpected, response.Messages); + + await DeleteMultipleMessages(_conversationId, _message2, _message3); + } + + private async Task AddMultipleMessages(string conversationId, params Message[] messages) + { + foreach (Message message in messages) + { + await _store.AddMessage(conversationId, message); + } + } + + private async Task DeleteMultipleMessages(string conversationId, params Message[] messages) + { + foreach (Message message in messages) + { + await _store.DeleteMessage(conversationId, message.id); + } + } + + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + await _store.DeleteMessage(_conversationId, _message1.id); + } +} \ No newline at end of file diff --git a/ChatService.Web/Dtos/Message.cs b/ChatService.Web/Dtos/Message.cs index 708d8fa..e0e0aa8 100644 --- a/ChatService.Web/Dtos/Message.cs +++ b/ChatService.Web/Dtos/Message.cs @@ -3,9 +3,10 @@ namespace ChatService.Web.Dtos; -public record Message( - [Required] string id, - [Required] UnixDateTime UnixTime, - [Required] string senderUsername, - [Required] string text -); \ No newline at end of file +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/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/Program.cs b/ChatService.Web/Program.cs index 9ba792d..2adf257 100644 --- a/ChatService.Web/Program.cs +++ b/ChatService.Web/Program.cs @@ -15,6 +15,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => { diff --git a/ChatService.Web/Storage/CosmosMessageStore.cs b/ChatService.Web/Storage/CosmosMessageStore.cs index b340b08..3efea59 100644 --- a/ChatService.Web/Storage/CosmosMessageStore.cs +++ b/ChatService.Web/Storage/CosmosMessageStore.cs @@ -1,16 +1,150 @@ +using System.Net; using ChatService.Web.Dtos; +using ChatService.Web.Enums; +using ChatService.Web.Exceptions; using ChatService.Web.Storage.Entities; +using Microsoft.Azure.Cosmos; +using Microsoft.Azure.Cosmos.Linq; namespace ChatService.Web.Storage; -public class CosmosMessageStore +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) + { + if (message == null || + string.IsNullOrWhiteSpace(message.id) || + string.IsNullOrWhiteSpace(message.senderUsername) || + string.IsNullOrWhiteSpace(message.text) + ) + { + throw new ArgumentException($"Invalid message {message}", nameof(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."); + } + throw; + } + } + + public async Task GetMessage(string conversationId, string messageId) + { + if (string.IsNullOrWhiteSpace(conversationId)) + { + throw new ArgumentException($"Invalid conversationId {conversationId}"); + } + if (string.IsNullOrWhiteSpace(messageId)) + { + throw new ArgumentException($"Invalid messageId {messageId}"); + } + + try + { + var entity = await Container.ReadItemAsync( + id: messageId, + partitionKey: new PartitionKey(conversationId), + new ItemRequestOptions + { + ConsistencyLevel = ConsistencyLevel.Session + } + ); + return ToMessage(entity); + } + catch (CosmosException e) + { + if (e.StatusCode == HttpStatusCode.NotFound) + { + throw new MessageNotFoundException($"A message with messageId {messageId} was not found."); + } + throw; + } + } + + public async Task<(List Messages, string NextContinuationToken)> GetMessages( + string conversationId, int limit, OrderBy order, string? continuationToken, long lastSeenMessageTime) + { + if (string.IsNullOrWhiteSpace(conversationId)) + { + throw new ArgumentException("ConversationId cannot be null or empty."); + } + if (limit <= 0) + { + throw new ArgumentException($"Invalid limit {limit}"); + } + + List messages = new (); + string nextContinuationToken = null; + + QueryRequestOptions options = new QueryRequestOptions(); + options.MaxItemCount = limit; + + IQueryable query = Container + .GetItemLinqQueryable(false, continuationToken, options) + .Where(e => e.partitionKey == conversationId && e.unixTime > lastSeenMessageTime); + + if (order == OrderBy.ASC) + { + query = query.OrderBy(e => e.unixTime); + } + else + { + query = query.OrderByDescending(e => e.unixTime); + } + + using (FeedIterator iterator = query.ToFeedIterator()) + { + FeedResponse response = await iterator.ReadNextAsync(); + var receivedUserConversations = response.Select(ToMessage); + + messages.AddRange(receivedUserConversations); + + nextContinuationToken = response.ContinuationToken; + }; + + return (messages, nextContinuationToken); + } + + public async Task DeleteMessage(string conversationId, string messageId) + { + try + { + await Container.DeleteItemAsync( + id: messageId, + partitionKey: new PartitionKey(conversationId)); + } + catch (CosmosException e) + { + if (e.StatusCode == HttpStatusCode.NotFound) + { + return; + } + throw; + } + } + private static MessageEntity ToEntity(string conversationId, Message message) { return new MessageEntity( partitionKey: conversationId, id: message.id, - message.UnixTime, + message.unixTime, message.senderUsername, message.text ); @@ -18,11 +152,12 @@ private static MessageEntity ToEntity(string conversationId, Message message) private static Message ToMessage(MessageEntity entity) { - return new Message( - id: entity.id, - entity.UnixTime, - entity.senderUsername, - entity.text - ); + return new Message + { + id = entity.id, + unixTime = entity.unixTime, + senderUsername = entity.senderUsername, + text = entity.text + }; } } \ No newline at end of file diff --git a/ChatService.Web/Storage/Entities/MessageEntity.cs b/ChatService.Web/Storage/Entities/MessageEntity.cs index 740cf3b..02c3adf 100644 --- a/ChatService.Web/Storage/Entities/MessageEntity.cs +++ b/ChatService.Web/Storage/Entities/MessageEntity.cs @@ -5,7 +5,7 @@ namespace ChatService.Web.Storage.Entities; public record MessageEntity( string partitionKey, string id, - UnixDateTime UnixTime, + long unixTime, string senderUsername, string text ); \ No newline at end of file diff --git a/ChatService.Web/Storage/IConversationStore.cs b/ChatService.Web/Storage/IConversationStore.cs index 531e225..caf969d 100644 --- a/ChatService.Web/Storage/IConversationStore.cs +++ b/ChatService.Web/Storage/IConversationStore.cs @@ -8,7 +8,7 @@ public interface IConversationStore { Task CreateUserConversation(UserConversation userConversation); Task GetUserConversation(string username, string conversationId); - Task<(List UserConversations, string NextContinuationToken)> GetUserConversations(string username, int limit, OrderBy order, - string? continuationToken, long lastSeenConversationTime); + Task<(List UserConversations, string NextContinuationToken)> GetUserConversations( + string username, int limit, OrderBy order, string? continuationToken, long lastSeenConversationTime); Task DeleteUserConversation(string username, string conversationId); } \ No newline at end of file diff --git a/ChatService.Web/Storage/IMessageStore.cs b/ChatService.Web/Storage/IMessageStore.cs index 5018d1f..878611d 100644 --- a/ChatService.Web/Storage/IMessageStore.cs +++ b/ChatService.Web/Storage/IMessageStore.cs @@ -1,9 +1,13 @@ using ChatService.Web.Dtos; +using ChatService.Web.Enums; namespace ChatService.Web.Storage; public interface IMessageStore { - Task AddMessage(Message message); - Task GetMessages(string conversationId, string continuationToken, int limit); -} \ No newline at end of file + Task AddMessage(string conversationId, Message message); + Task GetMessage(string conversationId, string messageId); + Task<(List Messages, string NextContinuationToken)> GetMessages( + string conversationId, int limit, OrderBy order, string? continuationToken, long lastSeenMessageTime); + Task DeleteMessage(string conversationId, string messageId); +} diff --git a/ChatService.sln.DotSettings.user b/ChatService.sln.DotSettings.user index fd7f47e..60cd354 100644 --- a/ChatService.sln.DotSettings.user +++ b/ChatService.sln.DotSettings.user @@ -3,10 +3,25 @@ On On On - C:\Users\nadim\AppData\Local\JetBrains\Rider2022.3\resharper-host\temp\Rider\vAny\CoverageData\_ChatService.34854384\Snapshot\snapshot.utdcvr - <SessionState ContinuousTestingMode="0" IsActive="True" Name="GetUserConversations_Ordered_Successful" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + + <SessionState ContinuousTestingMode="0" Name="GetUserConversations_Ordered_Successful" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <TestAncestor> <TestId>xUnit::A7808CB8-B561-4939-9B4E-8AC0FD786DC3::net6.0::ChatService.Web.IntegrationTests.CosmosConversationStoreTests</TestId> + <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ImageControllerTests</TestId> + </TestAncestor> +</SessionState> + <SessionState ContinuousTestingMode="0" Name="ProfileControllerTests" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <TestAncestor> + <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ProfileControllerTests</TestId> + <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Services.ProfileServiceTests</TestId> + <TestId>xUnit::A7808CB8-B561-4939-9B4E-8AC0FD786DC3::net6.0::ChatService.Web.IntegrationTests.CosmosMessageStoreTests.AddMessage_Successful</TestId> + <TestId>xUnit::A7808CB8-B561-4939-9B4E-8AC0FD786DC3::net6.0::ChatService.Web.IntegrationTests.CosmosMessageStoreTests.AddMessage_InvalidArguments</TestId> + <TestId>xUnit::A7808CB8-B561-4939-9B4E-8AC0FD786DC3::net6.0::ChatService.Web.IntegrationTests.CosmosMessageStoreTests</TestId> + </TestAncestor> +</SessionState> + <SessionState ContinuousTestingMode="0" IsActive="True" Name="CosmosMessageStoreTests" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <TestAncestor> + <TestId>xUnit::A7808CB8-B561-4939-9B4E-8AC0FD786DC3::net6.0::ChatService.Web.IntegrationTests.CosmosMessageStoreTests</TestId> </TestAncestor> </SessionState> <SessionState ContinuousTestingMode="0" Name="Session" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> From 5c1623c928b2186fc88ac4b4d73211be0cec0006 Mon Sep 17 00:00:00 2001 From: nadimakk Date: Wed, 29 Mar 2023 21:43:55 +0200 Subject: [PATCH 28/45] begin conversation service --- .../CosmosConversationStoreTests.cs | 106 +++++++++--------- .../CosmosMessageStoreTests.cs | 38 +++++-- .../Services/ProfileServiceTests.cs | 9 ++ ChatService.Web/Dtos/Conversation.cs | 11 +- .../Dtos/GetConversationsResponse.cs | 9 ++ ChatService.Web/Dtos/SendMessageRequest.cs | 10 ++ ChatService.Web/Dtos/SendMessageResponse.cs | 8 ++ .../Dtos/StartConversationRequest.cs | 9 ++ .../Dtos/StartConversationResponse.cs | 9 ++ .../Services/ConversationService.cs | 92 +++++++++++++++ .../Services/IConversationService.cs | 6 + ChatService.Web/Services/ProfileService.cs | 5 + .../Storage/CosmosConversationStore.cs | 10 +- ChatService.Web/Storage/CosmosMessageStore.cs | 10 +- ChatService.sln.DotSettings.user | 23 +--- 15 files changed, 262 insertions(+), 93 deletions(-) create mode 100644 ChatService.Web/Dtos/GetConversationsResponse.cs create mode 100644 ChatService.Web/Dtos/SendMessageRequest.cs create mode 100644 ChatService.Web/Dtos/SendMessageResponse.cs create mode 100644 ChatService.Web/Dtos/StartConversationRequest.cs create mode 100644 ChatService.Web/Dtos/StartConversationResponse.cs create mode 100644 ChatService.Web/Services/ConversationService.cs diff --git a/ChatService.Web.IntegrationTests/CosmosConversationStoreTests.cs b/ChatService.Web.IntegrationTests/CosmosConversationStoreTests.cs index b563717..1f6a437 100644 --- a/ChatService.Web.IntegrationTests/CosmosConversationStoreTests.cs +++ b/ChatService.Web.IntegrationTests/CosmosConversationStoreTests.cs @@ -51,20 +51,22 @@ public async Task CreateUserConversation_Successful() Assert.Equal(_userConversation, await _store.GetUserConversation(_userConversation.username, _userConversation.conversationId)); } - + [Theory] - [InlineData(null, "dummy_conversationId")] - [InlineData("", "dummy_conversationId")] - [InlineData(" ", "dummy_conversationId")] - [InlineData("foobar", null)] - [InlineData("foobar", "")] - [InlineData("foobar", " ")] - public async Task CreateUserConversation_InvalidArguments(string username, string conversationId) + [InlineData(null, "dummy_conversationId", 100)] + [InlineData("", "dummy_conversationId", 100)] + [InlineData(" ", "dummy_conversationId", 100)] + [InlineData("foobar", null, 100)] + [InlineData("foobar", "", 100)] + [InlineData("foobar", " ", 100)] + [InlineData("foobar", "dummy_conversationId", -100)] + public async Task CreateUserConversation_InvalidArguments(string username, string conversationId, long lastModifiedTime) { UserConversation userConversation = new() { username = username, - conversationId = conversationId + conversationId = conversationId, + lastModifiedTime = lastModifiedTime }; await Assert.ThrowsAsync( @@ -122,21 +124,13 @@ public async Task GetUserConversations_Limit() [InlineData(OrderBy.DESC)] public async Task GetUserConversations_OrderBy(OrderBy orderBy) { - UserConversation userConversationSecond = new UserConversation - { - username = _userConversation.username, - conversationId = Guid.NewGuid().ToString(), - lastModifiedTime = _userConversation.lastModifiedTime - }; + List userConversationsExpected = CreateListOfUserConversations( + _userConversation1, _userConversation2, _userConversation3, _userConversation); - await _store.CreateUserConversation(_userConversation); - await _store.CreateUserConversation(userConversationSecond); + await AddMultipleUserConversations( + _userConversation, _userConversation1, _userConversation2, _userConversation3); - List userConversationsExpected = new(); - userConversationsExpected.Add(_userConversation); - userConversationsExpected.Add(userConversationSecond); - - var response = await _store.GetUserConversations(_userConversation.username, 2, orderBy, null, 1); + var response = await _store.GetUserConversations(_userConversation.username, 10, orderBy, null, 0); if (orderBy == OrderBy.ASC) { @@ -148,17 +142,16 @@ public async Task GetUserConversations_OrderBy(OrderBy orderBy) Assert.Equal(userConversationsExpected, response.UserConversations); } - await _store.DeleteUserConversation(userConversationSecond.username, userConversationSecond.conversationId); + await DeleteMultipleUserConversations(_userConversation1, _userConversation2, _userConversation3); } [Fact] public async Task GetUserConversations_ContinuationTokenValidity() { - await _store.CreateUserConversation(_userConversation1); - await _store.CreateUserConversation(_userConversation2); - await _store.CreateUserConversation(_userConversation3); + await AddMultipleUserConversations(_userConversation1, _userConversation2, _userConversation3); - var response = await _store.GetUserConversations(_userConversation.username, 1, OrderBy.ASC, null, 1); + var response = await _store.GetUserConversations( + _userConversation.username, 1, OrderBy.ASC, null, 1); Assert.Equal(_userConversation1, response.UserConversations.ElementAt(0)); @@ -177,46 +170,43 @@ public async Task GetUserConversations_ContinuationTokenValidity() nextContinuation = response.NextContinuationToken; Assert.Null(nextContinuation); - await _store.DeleteUserConversation(_userConversation.username, _userConversation1.conversationId); - await _store.DeleteUserConversation(_userConversation.username, _userConversation2.conversationId); - await _store.DeleteUserConversation(_userConversation.username, _userConversation3.conversationId); + await DeleteMultipleUserConversations(_userConversation1, _userConversation2, _userConversation3); } - [Fact] - public async Task GetUserConversations_LastSeenConversationTime() + [Theory] + [InlineData(0)] + [InlineData(100)] + [InlineData(200)] + [InlineData(300)] + public async Task GetUserConversations_LastSeenConversationTime(long lastSeenConversationTime) { await AddMultipleUserConversations(_userConversation1, _userConversation2, _userConversation3, _userConversation); - List UnixTime50Expected = new List { _userConversation1, _userConversation2, _userConversation3, _userConversation }; - List UnixTime150Expected = new List { _userConversation2, _userConversation3, _userConversation }; - List UnixTime250Expected = new List { _userConversation3, _userConversation }; - List UnixTime350Expected = new List { _userConversation }; - - var response = await _store.GetUserConversations(_userConversation.username, 10, OrderBy.ASC, null, 50); - Assert.Equal(UnixTime50Expected, response.UserConversations); + List userConversationsExpected = new(); - response = await _store.GetUserConversations(_userConversation.username, 10, OrderBy.ASC, null, 150); - Assert.Equal(UnixTime150Expected, response.UserConversations); + if(_userConversation1.lastModifiedTime > lastSeenConversationTime) { userConversationsExpected.Add(_userConversation1); } + if(_userConversation2.lastModifiedTime > lastSeenConversationTime) { userConversationsExpected.Add(_userConversation2);} + if(_userConversation3.lastModifiedTime > lastSeenConversationTime) { userConversationsExpected.Add(_userConversation3);} + if(_userConversation.lastModifiedTime > lastSeenConversationTime) { userConversationsExpected.Add(_userConversation);} - response = await _store.GetUserConversations(_userConversation.username, 10, OrderBy.ASC, null, 250); - Assert.Equal(UnixTime250Expected, response.UserConversations); + var response = await _store.GetUserConversations(_userConversation.username, 10, OrderBy.ASC, null, lastSeenConversationTime); - response = await _store.GetUserConversations(_userConversation.username, 10, OrderBy.ASC, null, 350); - Assert.Equal(UnixTime350Expected, response.UserConversations); + Assert.Equal(userConversationsExpected, response.UserConversations); await DeleteMultipleUserConversations(_userConversation1, _userConversation2, _userConversation3); } [Theory] - [InlineData("", 1)] - [InlineData(" ", 1)] - [InlineData(null, 0)] - [InlineData("username", 0)] - [InlineData("username", -1)] - public async Task GetUserConversations_InvalidArguments(string username, int limit) + [InlineData("", 1, 100)] + [InlineData(" ", 1, 100)] + [InlineData(null, 0, 100)] + [InlineData("username", 0, 100)] + [InlineData("username", -1, 100)] + [InlineData("username", 10, -100)] + public async Task GetUserConversations_InvalidArguments(string username, int limit, long lastSeenConversationTime) { await Assert.ThrowsAsync( - () => _store.GetUserConversations(username, limit, OrderBy.ASC, null, 1)); + () => _store.GetUserConversations(username, limit, OrderBy.ASC, null, lastSeenConversationTime)); } private async Task AddMultipleUserConversations(params UserConversation[] userConversations) @@ -227,6 +217,18 @@ private async Task AddMultipleUserConversations(params UserConversation[] userCo } } + private List CreateListOfUserConversations(params UserConversation[] userConversations) + { + List list = new(); + + foreach (UserConversation userConversation in userConversations) + { + list.Add(userConversation); + } + + return list; + } + private async Task DeleteMultipleUserConversations(params UserConversation[] userConversations) { foreach (UserConversation userConversation in userConversations) diff --git a/ChatService.Web.IntegrationTests/CosmosMessageStoreTests.cs b/ChatService.Web.IntegrationTests/CosmosMessageStoreTests.cs index 5bd37b3..bb17db4 100644 --- a/ChatService.Web.IntegrationTests/CosmosMessageStoreTests.cs +++ b/ChatService.Web.IntegrationTests/CosmosMessageStoreTests.cs @@ -51,21 +51,22 @@ public async Task AddMessage_Successful() } [Theory] - [InlineData(null, "senderUsername", "text")] - [InlineData("", "senderUsername", "text")] - [InlineData(" ", "senderUsername", "text")] - [InlineData("id", null, "text")] - [InlineData("id", "", "text")] - [InlineData("id", " ", "text")] - [InlineData("id", "senderUsername", null)] - [InlineData("id", "senderUsername", "")] - [InlineData("id", "senderUsername", " ")] - public async Task AddMessage_InvalidArguments(string id, string senderUsername, string text) + [InlineData(null, "senderUsername", "text", 100)] + [InlineData("", "senderUsername", "text", 100)] + [InlineData(" ", "senderUsername", "text", 100)] + [InlineData("id", null, "text", 100)] + [InlineData("id", "", "text", 100)] + [InlineData("id", " ", "text", 100)] + [InlineData("id", "senderUsername", null, 100)] + [InlineData("id", "senderUsername", "", 100)] + [InlineData("id", "senderUsername", " ", 100)] + [InlineData("id", "senderUsername", "text", -100)] + public async Task AddMessage_InvalidArguments(string id, string senderUsername, string text, long unixTime) { Message message = new Message { id = id, - unixTime = 100, + unixTime = unixTime, senderUsername = senderUsername, text = text }; @@ -188,7 +189,20 @@ public async Task GetMessages_LastSeenMessageTime(long lastSeenMessageTime) await DeleteMultipleMessages(_conversationId, _message2, _message3); } - + + [Theory] + [InlineData(null, 10, 100)] + [InlineData("", 10, 100)] + [InlineData(" ", 10, 100)] + [InlineData("conversationId", 0, 100)] + [InlineData("conversationId", -10, 100)] + [InlineData("conversationId", 10, -100)] + public async Task GetMessages_InvalidArguments(string conversationId, int limit, long lastSeenMessageTime) + { + Assert.ThrowsAsync(() => + _store.GetMessages(conversationId, limit, OrderBy.ASC, null, lastSeenMessageTime)); + } + private async Task AddMultipleMessages(string conversationId, params Message[] messages) { foreach (Message message in messages) diff --git a/ChatService.Web.Tests/Services/ProfileServiceTests.cs b/ChatService.Web.Tests/Services/ProfileServiceTests.cs index 6cd73e2..31fed8c 100644 --- a/ChatService.Web.Tests/Services/ProfileServiceTests.cs +++ b/ChatService.Web.Tests/Services/ProfileServiceTests.cs @@ -40,6 +40,15 @@ public async Task GetProfile_Success() Assert.Equal(_profile, receivedProfile); } + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task GetProfile_InvalidArguments(string username) + { + Assert.ThrowsAsync(() => _profileService.GetProfile(username)); + } + [Fact] public async Task AddNewProfile_Success() { diff --git a/ChatService.Web/Dtos/Conversation.cs b/ChatService.Web/Dtos/Conversation.cs index 8b50972..f40d8ca 100644 --- a/ChatService.Web/Dtos/Conversation.cs +++ b/ChatService.Web/Dtos/Conversation.cs @@ -3,8 +3,9 @@ namespace ChatService.Web.Dtos; -public record Conversation( - [Required] string id, - [Required] UnixDateTime lastModifiedTime, - [Required] List participants - ); \ No newline at end of file +public record Conversation +{ + [Required] private string id { get; set; } + [Required] long lastModifiedUnixTime { get; set; } + [Required] private Profile recipient { get; set; } +} \ No newline at end of file diff --git a/ChatService.Web/Dtos/GetConversationsResponse.cs b/ChatService.Web/Dtos/GetConversationsResponse.cs new file mode 100644 index 0000000..1ee0917 --- /dev/null +++ b/ChatService.Web/Dtos/GetConversationsResponse.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatService.Web.Dtos; + +public record GetConversationsResponse +{ + [Required] public List conversations { get; set; } + [Required] public string nextContinuationToken { 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..746f989 --- /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..ed223f9 --- /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..2648a5d --- /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/Services/ConversationService.cs b/ChatService.Web/Services/ConversationService.cs new file mode 100644 index 0000000..efc165c --- /dev/null +++ b/ChatService.Web/Services/ConversationService.cs @@ -0,0 +1,92 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Enums; +using ChatService.Web.Storage; + +namespace ChatService.Web.Services; + +public class ConversationService : IConversationService +{ + + private readonly IMessageStore _messageStore; + private readonly IConversationStore _conversationStore; + + public ConversationService(IMessageStore messageStore, IConversationStore conversationStore) + { + _messageStore = messageStore; + _conversationStore = conversationStore; + } + + public async Task CreateConversation(StartConversationRequest request) + { + //TODO: + //////SPLIT THIS INTO MULTIPLE IFS TO THROW A DIFFERENT ARGUMENT EXCEPTION FOR EACH + if (request == null || + request.participants.Count < 2 || + //TODO: + string.IsNullOrEmpty(request.participants.ElementAt(0)) || //WRITE A TEST TO SEE THE PYTHON THING!!!!!!!!!!!!!! + string.IsNullOrEmpty(request.participants.ElementAt(1)) || + request.participants.ElementAt(0).Equals(request.participants.ElementAt(1)) || + string.IsNullOrEmpty(request.firstMessage.id) || + string.IsNullOrEmpty(request.firstMessage.SenderUsername) || + string.IsNullOrEmpty(request.firstMessage.text) + ) + { + throw new ArgumentException($"Invalid StartConversationRequest {request}."); + } + + string conversationId; + string username1 = request.participants.ElementAt(0); + string username2 = request.participants.ElementAt(1); + + if (username1.CompareTo(username2) < 0) + { + conversationId = username1 + "_" + username2; + } + else + { + conversationId = username2 + "_" + username1; + } + + long unixTimeNow = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + //TODO: + ////////////// MOVE THIS TO MESSAGE SERVICE and call it from here + Message message = new Message + { + id = request.firstMessage.id, + unixTime = unixTimeNow, + senderUsername = request.firstMessage.SenderUsername, + text = request.firstMessage.text + }; + await _messageStore.AddMessage(conversationId, message); + ////////////////////////////////////////////////////////// + + UserConversation userConversation1 = new UserConversation + { + username = username1, + conversationId = conversationId, + lastModifiedTime = unixTimeNow + }; + await _conversationStore.CreateUserConversation(userConversation1); + + UserConversation userConversation2 = new UserConversation + { + username = username2, + conversationId = conversationId, + lastModifiedTime = unixTimeNow + }; + await _conversationStore.CreateUserConversation(userConversation2); + + return new StartConversationResponse + { + Id = conversationId, + CreatedUnixTime = unixTimeNow + }; + } + + public Task GetConversations( + string username, string limit, OrderBy orderBy, string? continuationToken, string lastSeenConversationTime) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/ChatService.Web/Services/IConversationService.cs b/ChatService.Web/Services/IConversationService.cs index 986989a..fe92276 100644 --- a/ChatService.Web/Services/IConversationService.cs +++ b/ChatService.Web/Services/IConversationService.cs @@ -1,5 +1,11 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Enums; + namespace ChatService.Web.Services; public interface IConversationService { + Task CreateConversation(StartConversationRequest request); + Task GetConversations( + string username, string limit, OrderBy orderBy, string? continuationToken, string lastSeenConversationTime); } \ No newline at end of file diff --git a/ChatService.Web/Services/ProfileService.cs b/ChatService.Web/Services/ProfileService.cs index 2eb42f9..195d5dd 100644 --- a/ChatService.Web/Services/ProfileService.cs +++ b/ChatService.Web/Services/ProfileService.cs @@ -17,6 +17,11 @@ public ProfileService(IProfileStore profileStore, IImageStore imageStore) public async Task GetProfile(string username) { + //MAKE SURE THIS CHECK IS CORRECT + if (string.IsNullOrWhiteSpace(username)) + { + throw new ArgumentException($"Invalid username {username}"); + } return await _profileStore.GetProfile(username); } diff --git a/ChatService.Web/Storage/CosmosConversationStore.cs b/ChatService.Web/Storage/CosmosConversationStore.cs index 2816343..4684af5 100644 --- a/ChatService.Web/Storage/CosmosConversationStore.cs +++ b/ChatService.Web/Storage/CosmosConversationStore.cs @@ -23,7 +23,8 @@ public async Task CreateUserConversation(UserConversation userConversation) { if (userConversation == null || string.IsNullOrWhiteSpace(userConversation.username) || - string.IsNullOrWhiteSpace(userConversation.conversationId) + string.IsNullOrWhiteSpace(userConversation.conversationId) || + userConversation.lastModifiedTime < 0 ) { throw new ArgumentException($"Invalid user conversation {userConversation}", nameof(userConversation)); @@ -87,9 +88,14 @@ public async Task GetUserConversation(string username, string { throw new ArgumentException($"Invalid limit {limit}"); } + + if (lastSeenConversationTime < 0) + { + throw new ArgumentException($"Invalid lastSeenConversationTime {lastSeenConversationTime}"); + } List userConversations = new (); - string nextContinuationToken = null; + string? nextContinuationToken = null; QueryRequestOptions options = new QueryRequestOptions(); options.MaxItemCount = limit; diff --git a/ChatService.Web/Storage/CosmosMessageStore.cs b/ChatService.Web/Storage/CosmosMessageStore.cs index 3efea59..64db3c9 100644 --- a/ChatService.Web/Storage/CosmosMessageStore.cs +++ b/ChatService.Web/Storage/CosmosMessageStore.cs @@ -24,7 +24,8 @@ public async Task AddMessage(string conversationId, Message message) if (message == null || string.IsNullOrWhiteSpace(message.id) || string.IsNullOrWhiteSpace(message.senderUsername) || - string.IsNullOrWhiteSpace(message.text) + string.IsNullOrWhiteSpace(message.text) || + message.unixTime < 0 ) { throw new ArgumentException($"Invalid message {message}", nameof(message)); @@ -88,9 +89,14 @@ public async Task GetMessage(string conversationId, string messageId) { throw new ArgumentException($"Invalid limit {limit}"); } + + if (lastSeenMessageTime < 0) + { + throw new ArgumentException($"Invalid lastSeenMessageTime {lastSeenMessageTime}"); + } List messages = new (); - string nextContinuationToken = null; + string? nextContinuationToken = null; QueryRequestOptions options = new QueryRequestOptions(); options.MaxItemCount = limit; diff --git a/ChatService.sln.DotSettings.user b/ChatService.sln.DotSettings.user index 60cd354..11a1edb 100644 --- a/ChatService.sln.DotSettings.user +++ b/ChatService.sln.DotSettings.user @@ -3,26 +3,9 @@ On On On - - <SessionState ContinuousTestingMode="0" Name="GetUserConversations_Ordered_Successful" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <TestAncestor> - <TestId>xUnit::A7808CB8-B561-4939-9B4E-8AC0FD786DC3::net6.0::ChatService.Web.IntegrationTests.CosmosConversationStoreTests</TestId> - <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ImageControllerTests</TestId> - </TestAncestor> -</SessionState> - <SessionState ContinuousTestingMode="0" Name="ProfileControllerTests" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <TestAncestor> - <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ProfileControllerTests</TestId> - <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Services.ProfileServiceTests</TestId> - <TestId>xUnit::A7808CB8-B561-4939-9B4E-8AC0FD786DC3::net6.0::ChatService.Web.IntegrationTests.CosmosMessageStoreTests.AddMessage_Successful</TestId> - <TestId>xUnit::A7808CB8-B561-4939-9B4E-8AC0FD786DC3::net6.0::ChatService.Web.IntegrationTests.CosmosMessageStoreTests.AddMessage_InvalidArguments</TestId> - <TestId>xUnit::A7808CB8-B561-4939-9B4E-8AC0FD786DC3::net6.0::ChatService.Web.IntegrationTests.CosmosMessageStoreTests</TestId> - </TestAncestor> -</SessionState> - <SessionState ContinuousTestingMode="0" IsActive="True" Name="CosmosMessageStoreTests" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <TestAncestor> - <TestId>xUnit::A7808CB8-B561-4939-9B4E-8AC0FD786DC3::net6.0::ChatService.Web.IntegrationTests.CosmosMessageStoreTests</TestId> - </TestAncestor> + C:\Users\nadim\AppData\Local\JetBrains\Rider2022.3\resharper-host\temp\Rider\vAny\CoverageData\_ChatService.34854384\Snapshot\snapshot.utdcvr + <SessionState ContinuousTestingMode="0" IsActive="True" Name="GetUserConversations_Ordered_Successful" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Solution /> </SessionState> <SessionState ContinuousTestingMode="0" Name="Session" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> From f4a76d3cfa3a1fd542a4ca87cd87cec03c3017f2 Mon Sep 17 00:00:00 2001 From: nadimakk Date: Thu, 30 Mar 2023 12:03:29 +0300 Subject: [PATCH 29/45] complete conversation service --- ChatService.Web/Dtos/Conversation.cs | 6 +- ChatService.Web/Dtos/GetMessagesResponse.cs | 9 ++ .../Exceptions/ProfileNotFoundException.cs | 8 ++ .../Services/ConversationService.cs | 104 +++++++++++++++--- .../Services/IConversationService.cs | 2 +- ChatService.Web/Services/IMessageService.cs | 11 ++ ChatService.Web/Services/IProfileService.cs | 1 + ChatService.Web/Services/MessageService.cs | 18 +++ ChatService.Web/Services/ProfileService.cs | 13 ++- .../Storage/CosmosConversationStore.cs | 5 +- ChatService.Web/Storage/CosmosMessageStore.cs | 5 +- ChatService.Web/Storage/CosmosProfileStore.cs | 23 +--- 12 files changed, 162 insertions(+), 43 deletions(-) create mode 100644 ChatService.Web/Dtos/GetMessagesResponse.cs create mode 100644 ChatService.Web/Exceptions/ProfileNotFoundException.cs create mode 100644 ChatService.Web/Services/IMessageService.cs create mode 100644 ChatService.Web/Services/MessageService.cs diff --git a/ChatService.Web/Dtos/Conversation.cs b/ChatService.Web/Dtos/Conversation.cs index f40d8ca..b6a6338 100644 --- a/ChatService.Web/Dtos/Conversation.cs +++ b/ChatService.Web/Dtos/Conversation.cs @@ -5,7 +5,7 @@ namespace ChatService.Web.Dtos; public record Conversation { - [Required] private string id { get; set; } - [Required] long lastModifiedUnixTime { get; set; } - [Required] private Profile recipient { get; set; } + [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/GetMessagesResponse.cs b/ChatService.Web/Dtos/GetMessagesResponse.cs new file mode 100644 index 0000000..c75b2d3 --- /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 nextContinuationToken { get; set; } +} \ No newline at end of file diff --git a/ChatService.Web/Exceptions/ProfileNotFoundException.cs b/ChatService.Web/Exceptions/ProfileNotFoundException.cs new file mode 100644 index 0000000..aa8eb9d --- /dev/null +++ b/ChatService.Web/Exceptions/ProfileNotFoundException.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Exceptions; + +public class ProfileNotFoundException : Exception +{ + public ProfileNotFoundException(string? message) : base(message) + { + } +} \ No newline at end of file diff --git a/ChatService.Web/Services/ConversationService.cs b/ChatService.Web/Services/ConversationService.cs index efc165c..142945a 100644 --- a/ChatService.Web/Services/ConversationService.cs +++ b/ChatService.Web/Services/ConversationService.cs @@ -1,5 +1,6 @@ using ChatService.Web.Dtos; using ChatService.Web.Enums; +using ChatService.Web.Exceptions; using ChatService.Web.Storage; namespace ChatService.Web.Services; @@ -9,35 +10,52 @@ public class ConversationService : IConversationService private readonly IMessageStore _messageStore; private readonly IConversationStore _conversationStore; + private readonly IProfileService _profileService; - public ConversationService(IMessageStore messageStore, IConversationStore conversationStore) + public ConversationService(IMessageStore messageStore, IConversationStore conversationStore, IProfileService profileService) { _messageStore = messageStore; _conversationStore = conversationStore; + _profileService = profileService; } public async Task CreateConversation(StartConversationRequest request) { - //TODO: - //////SPLIT THIS INTO MULTIPLE IFS TO THROW A DIFFERENT ARGUMENT EXCEPTION FOR EACH - if (request == null || - request.participants.Count < 2 || + if (request == null) + { + throw new ArgumentException($"StartConversationRequest is null."); + } + + if (request.participants.Count < 2 || //TODO: string.IsNullOrEmpty(request.participants.ElementAt(0)) || //WRITE A TEST TO SEE THE PYTHON THING!!!!!!!!!!!!!! string.IsNullOrEmpty(request.participants.ElementAt(1)) || - request.participants.ElementAt(0).Equals(request.participants.ElementAt(1)) || - string.IsNullOrEmpty(request.firstMessage.id) || - string.IsNullOrEmpty(request.firstMessage.SenderUsername) || - string.IsNullOrEmpty(request.firstMessage.text) - ) + request.participants.ElementAt(0).Equals(request.participants.ElementAt(1))) { - throw new ArgumentException($"Invalid StartConversationRequest {request}."); + throw new ArgumentException( + $"Invalid participants list ${request.participants}. There must be 2 unique participant usernames"); } + if (string.IsNullOrEmpty(request.firstMessage.id) || + string.IsNullOrEmpty(request.firstMessage.SenderUsername) || + string.IsNullOrEmpty(request.firstMessage.text)) + { + throw new ArgumentException($"Invalid FirstMessage {request.firstMessage}."); + } + string conversationId; string username1 = request.participants.ElementAt(0); string username2 = request.participants.ElementAt(1); + if (!await _profileService.ProfileExists(username1)) + { + throw new ProfileNotFoundException($"A profile with the username {username1} was not found."); + } + if (!await _profileService.ProfileExists(username2)) + { + throw new ProfileNotFoundException($"A profile with the username {username2} was not found."); + } + if (username1.CompareTo(username2) < 0) { conversationId = username1 + "_" + username2; @@ -84,9 +102,67 @@ public async Task CreateConversation(StartConversatio }; } - public Task GetConversations( - string username, string limit, OrderBy orderBy, string? continuationToken, string lastSeenConversationTime) + public async Task GetConversations( + string username, int limit, OrderBy orderBy, string? continuationToken, long lastSeenConversationTime) { - throw new NotImplementedException(); + if (string.IsNullOrEmpty(username)) + { + throw new ArgumentException($"Invalid username {username}."); + } + + if (limit <= 0) + { + throw new ArgumentException($"Invalid limit {limit}. Limit must be greater or equal to 1."); + } + + if (lastSeenConversationTime < 0) + { + throw new ArgumentException( + $"Invalid lastSeenConversationTime {lastSeenConversationTime}. lastSeenConversationTime must be greater or equal to 0."); + } + + var result = await _conversationStore.GetUserConversations( + username, limit, orderBy, continuationToken, lastSeenConversationTime); + + List conversations = await UserConversationsToConversations(result.UserConversations); + + return new GetConversationsResponse + { + conversations = conversations, + nextContinuationToken = result.NextContinuationToken + }; + } + + private async Task> UserConversationsToConversations(List userConversations) + { + List conversations = new(); + + foreach (UserConversation userConversation in userConversations) + { + string[] usernames = userConversation.conversationId.Split('_'); + string recipientUsername; + + if (usernames[0].Equals(userConversation.username)) + { + recipientUsername = usernames[1]; + } + else + { + recipientUsername = usernames[0]; + } + + Profile recipientProfile = await _profileService.GetProfile(recipientUsername); + + Conversation conversation = new Conversation + { + id = userConversation.conversationId, + lastModifiedUnixTime = userConversation.lastModifiedTime, + recipient = recipientProfile + }; + + conversations.Add(conversation); + } + + return conversations; } } \ No newline at end of file diff --git a/ChatService.Web/Services/IConversationService.cs b/ChatService.Web/Services/IConversationService.cs index fe92276..9f25ff0 100644 --- a/ChatService.Web/Services/IConversationService.cs +++ b/ChatService.Web/Services/IConversationService.cs @@ -7,5 +7,5 @@ public interface IConversationService { Task CreateConversation(StartConversationRequest request); Task GetConversations( - string username, string limit, OrderBy orderBy, string? continuationToken, string lastSeenConversationTime); + string username, int limit, OrderBy orderBy, string? continuationToken, long lastSeenConversationTime); } \ 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..a588bfa --- /dev/null +++ b/ChatService.Web/Services/IMessageService.cs @@ -0,0 +1,11 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Enums; + +namespace ChatService.Web.Services; + +public interface IMessageService +{ + Task AddMessage(SendMessageRequest request); + Task GetMessages( + string conversationId, int limit, OrderBy orderBy, string? continuationToken, long lastSeenConversationTime); +} \ No newline at end of file diff --git a/ChatService.Web/Services/IProfileService.cs b/ChatService.Web/Services/IProfileService.cs index 6eb6662..28c493d 100644 --- a/ChatService.Web/Services/IProfileService.cs +++ b/ChatService.Web/Services/IProfileService.cs @@ -6,5 +6,6 @@ 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/MessageService.cs b/ChatService.Web/Services/MessageService.cs new file mode 100644 index 0000000..0d81261 --- /dev/null +++ b/ChatService.Web/Services/MessageService.cs @@ -0,0 +1,18 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Enums; + +namespace ChatService.Web.Services; + +public class MessageService : IMessageService +{ + public Task AddMessage(SendMessageRequest request) + { + throw new NotImplementedException(); + } + + public Task GetMessages(string conversationId, int limit, OrderBy orderBy, string? continuationToken, + long lastSeenConversationTime) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/ChatService.Web/Services/ProfileService.cs b/ChatService.Web/Services/ProfileService.cs index 195d5dd..9b5b1d0 100644 --- a/ChatService.Web/Services/ProfileService.cs +++ b/ChatService.Web/Services/ProfileService.cs @@ -45,12 +45,23 @@ public async Task AddProfile(Profile profile) bool imageExists = await _imageStore.ImageExists(profile.profilePictureId); if (!imageExists) { - throw new ImageNotFoundException("Invalid profile picture ID."); + throw new ImageNotFoundException( + $"Profile picture with ID {profile.profilePictureId} was not found."); } await _profileStore.AddProfile(profile); } + public async Task ProfileExists(string username) + { + if (string.IsNullOrWhiteSpace(username)) + { + throw new ArgumentException($"Invalid username {username}."); + + } + return await _profileStore.ProfileExists(username); + } + public async Task DeleteProfile(string username) { Profile? profile = await GetProfile(username); diff --git a/ChatService.Web/Storage/CosmosConversationStore.cs b/ChatService.Web/Storage/CosmosConversationStore.cs index 4684af5..e06502f 100644 --- a/ChatService.Web/Storage/CosmosConversationStore.cs +++ b/ChatService.Web/Storage/CosmosConversationStore.cs @@ -86,12 +86,13 @@ public async Task GetUserConversation(string username, string } if (limit <= 0) { - throw new ArgumentException($"Invalid limit {limit}"); + throw new ArgumentException($"Invalid limit {limit}. Limit must be greater or equal to 1."); } if (lastSeenConversationTime < 0) { - throw new ArgumentException($"Invalid lastSeenConversationTime {lastSeenConversationTime}"); + throw new ArgumentException( + $"Invalid lastSeenConversationTime {lastSeenConversationTime}. lastSeenConversationTime must be greater or equal to 0."); } List userConversations = new (); diff --git a/ChatService.Web/Storage/CosmosMessageStore.cs b/ChatService.Web/Storage/CosmosMessageStore.cs index 64db3c9..3cbd7ed 100644 --- a/ChatService.Web/Storage/CosmosMessageStore.cs +++ b/ChatService.Web/Storage/CosmosMessageStore.cs @@ -87,12 +87,13 @@ public async Task GetMessage(string conversationId, string messageId) } if (limit <= 0) { - throw new ArgumentException($"Invalid limit {limit}"); + throw new ArgumentException($"Invalid limit {limit}. Limit must be greater or equal to 1."); } if (lastSeenMessageTime < 0) { - throw new ArgumentException($"Invalid lastSeenMessageTime {lastSeenMessageTime}"); + throw new ArgumentException( + $"Invalid lastSeenMessageTime {lastSeenMessageTime}. lastSeenMessageTime must be greater or equal to 0."); } List messages = new (); diff --git a/ChatService.Web/Storage/CosmosProfileStore.cs b/ChatService.Web/Storage/CosmosProfileStore.cs index 3a7c94b..631658a 100644 --- a/ChatService.Web/Storage/CosmosProfileStore.cs +++ b/ChatService.Web/Storage/CosmosProfileStore.cs @@ -87,26 +87,9 @@ await Container.DeleteItemAsync( public async Task ProfileExists(string username) { - try - { - await Container.ReadItemAsync( - id: username, - partitionKey: new PartitionKey(username), - new ItemRequestOptions - { - ConsistencyLevel = ConsistencyLevel.Session - } - ); - return true; - } - catch (CosmosException e) - { - if (e.StatusCode == HttpStatusCode.NotFound) - { - return false; - } - throw; - } + Profile profile = await GetProfile(username); + + return profile != null; } private static ProfileEntity ToEntity(Profile profile) From 1ca4e88e39e0448cbd01d98a8eea44b7d28e09a0 Mon Sep 17 00:00:00 2001 From: nadimakk Date: Thu, 30 Mar 2023 14:41:13 +0300 Subject: [PATCH 30/45] complete message service --- .../CosmosMessageStoreTests.cs | 14 +++ .../ConversationPartitionDoesNotExist.cs | 8 ++ .../Exceptions/UserNotParticipantException.cs | 8 ++ .../Services/ConversationService.cs | 18 ++-- ChatService.Web/Services/IMessageService.cs | 2 +- ChatService.Web/Services/MessageService.cs | 102 +++++++++++++++++- ChatService.Web/Storage/CosmosMessageStore.cs | 8 ++ ChatService.Web/Storage/IMessageStore.cs | 1 + ChatService.sln.DotSettings.user | 4 +- 9 files changed, 148 insertions(+), 17 deletions(-) create mode 100644 ChatService.Web/Exceptions/ConversationPartitionDoesNotExist.cs create mode 100644 ChatService.Web/Exceptions/UserNotParticipantException.cs diff --git a/ChatService.Web.IntegrationTests/CosmosMessageStoreTests.cs b/ChatService.Web.IntegrationTests/CosmosMessageStoreTests.cs index bb17db4..053c09d 100644 --- a/ChatService.Web.IntegrationTests/CosmosMessageStoreTests.cs +++ b/ChatService.Web.IntegrationTests/CosmosMessageStoreTests.cs @@ -203,6 +203,20 @@ public async Task GetMessages_InvalidArguments(string conversationId, int limit, _store.GetMessages(conversationId, limit, OrderBy.ASC, null, lastSeenMessageTime)); } + [Fact] + public async Task ConversationPartitionExists_Exists() + { + await _store.AddMessage(_conversationId, _message1); + + Assert.True(await _store.ConversationPartitionExists(_conversationId)); + } + + [Fact] + public async Task ConversationPartitionExists_DoesNotExists() + { + Assert.False(await _store.ConversationPartitionExists(_conversationId)); + } + private async Task AddMultipleMessages(string conversationId, params Message[] messages) { foreach (Message message in messages) diff --git a/ChatService.Web/Exceptions/ConversationPartitionDoesNotExist.cs b/ChatService.Web/Exceptions/ConversationPartitionDoesNotExist.cs new file mode 100644 index 0000000..1cc7463 --- /dev/null +++ b/ChatService.Web/Exceptions/ConversationPartitionDoesNotExist.cs @@ -0,0 +1,8 @@ +namespace ChatService.Web.Exceptions; + +public class ConversationPartitionDoesNotExist : Exception +{ + public ConversationPartitionDoesNotExist(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/Services/ConversationService.cs b/ChatService.Web/Services/ConversationService.cs index 142945a..559a16b 100644 --- a/ChatService.Web/Services/ConversationService.cs +++ b/ChatService.Web/Services/ConversationService.cs @@ -8,13 +8,13 @@ namespace ChatService.Web.Services; public class ConversationService : IConversationService { - private readonly IMessageStore _messageStore; + private readonly IMessageService _messageService; private readonly IConversationStore _conversationStore; private readonly IProfileService _profileService; - public ConversationService(IMessageStore messageStore, IConversationStore conversationStore, IProfileService profileService) + public ConversationService(IMessageService messageService, IConversationStore conversationStore, IProfileService profileService) { - _messageStore = messageStore; + _messageService = messageService; _conversationStore = conversationStore; _profileService = profileService; } @@ -43,7 +43,6 @@ public async Task CreateConversation(StartConversatio throw new ArgumentException($"Invalid FirstMessage {request.firstMessage}."); } - string conversationId; string username1 = request.participants.ElementAt(0); string username2 = request.participants.ElementAt(1); @@ -56,6 +55,8 @@ public async Task CreateConversation(StartConversatio throw new ProfileNotFoundException($"A profile with the username {username2} was not found."); } + string conversationId; + if (username1.CompareTo(username2) < 0) { conversationId = username1 + "_" + username2; @@ -68,15 +69,14 @@ public async Task CreateConversation(StartConversatio long unixTimeNow = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); //TODO: - ////////////// MOVE THIS TO MESSAGE SERVICE and call it from here - Message message = new Message + ////////////// MOVED TO message servicve -- make sure all gucci + SendMessageRequest sendMessageRequest = new SendMessageRequest { id = request.firstMessage.id, - unixTime = unixTimeNow, - senderUsername = request.firstMessage.SenderUsername, + SenderUsername = request.firstMessage.SenderUsername, text = request.firstMessage.text }; - await _messageStore.AddMessage(conversationId, message); + await _messageService.AddMessage(conversationId, true, sendMessageRequest); ////////////////////////////////////////////////////////// UserConversation userConversation1 = new UserConversation diff --git a/ChatService.Web/Services/IMessageService.cs b/ChatService.Web/Services/IMessageService.cs index a588bfa..73c6e84 100644 --- a/ChatService.Web/Services/IMessageService.cs +++ b/ChatService.Web/Services/IMessageService.cs @@ -5,7 +5,7 @@ namespace ChatService.Web.Services; public interface IMessageService { - Task AddMessage(SendMessageRequest request); + Task AddMessage(string conversationId, bool isFirstMessage, SendMessageRequest request); Task GetMessages( string conversationId, int limit, OrderBy orderBy, string? continuationToken, long lastSeenConversationTime); } \ No newline at end of file diff --git a/ChatService.Web/Services/MessageService.cs b/ChatService.Web/Services/MessageService.cs index 0d81261..53752de 100644 --- a/ChatService.Web/Services/MessageService.cs +++ b/ChatService.Web/Services/MessageService.cs @@ -1,18 +1,110 @@ using ChatService.Web.Dtos; using ChatService.Web.Enums; +using ChatService.Web.Exceptions; +using ChatService.Web.Storage; namespace ChatService.Web.Services; public class MessageService : IMessageService { - public Task AddMessage(SendMessageRequest request) + private readonly IMessageStore _messageStore; + private readonly IProfileService _profileService; + private readonly IConversationService _conversationService; + + public MessageService(IMessageStore messageStore, IProfileService profileService, + IConversationService conversationService) + { + _messageStore = messageStore; + _profileService = profileService; + _conversationService = conversationService; + } + + public async Task AddMessage(string conversationId, bool isFirstMessage, + SendMessageRequest request) { - throw new NotImplementedException(); + if (request == null || + string.IsNullOrEmpty(request.id) || + string.IsNullOrEmpty(request.SenderUsername) || + string.IsNullOrEmpty(request.text) + ) + { + throw new ArgumentException($"Invalid SendMessageRequest {request}."); + } + + if (string.IsNullOrEmpty(conversationId)) + { + throw new ArgumentException($"Invalid conversationId {conversationId}."); + } + + //check if converstionId contains sender username to know if they are allowed to send message here + if (!conversationId.Contains(request.SenderUsername)) + { + //TODO: 403 error code in controller + throw new UserNotParticipantException( + $"User {request.SenderUsername} is not a participant of conversation {conversationId}."); + } + + //check if the sender's profile exists + if (!await _profileService.ProfileExists(request.SenderUsername)) + { + throw new ProfileNotFoundException( + $"A profile with the username {request.SenderUsername} was not found."); + } + + //if this is NOT the first message, check if the conversation already exists + if (!isFirstMessage && !await _messageStore.ConversationPartitionExists(conversationId)) + { + throw new ConversationPartitionDoesNotExist( + $"A conversation partition with the conversationId {conversationId} does not exist."); + } + //if it IS the first message, then its ok if the conversation does not exist as the partition will be created + + //add the message to the conversation partition + long unixTimeNow = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + Message message = new Message + { + id = request.id, + unixTime = unixTimeNow, + senderUsername = request.SenderUsername, + text = request.text + }; + + await _messageStore.AddMessage(conversationId, message); + //////////////////////////////////// + + return new SendMessageResponse + { + CreatedUnixTime = unixTimeNow + }; } - public Task GetMessages(string conversationId, int limit, OrderBy orderBy, string? continuationToken, - long lastSeenConversationTime) + public async Task GetMessages(string conversationId, int limit, OrderBy orderBy, + string? continuationToken, long lastSeenConversationTime) { - throw new NotImplementedException(); + if (string.IsNullOrEmpty(conversationId)) + { + throw new ArgumentException($"Invalid conversationId {conversationId}."); + } + + if (limit <= 0) + { + throw new ArgumentException($"Invalid limit {limit}. Limit must be greater or equal to 1."); + } + + if (lastSeenConversationTime < 0) + { + throw new ArgumentException( + $"Invalid lastSeenConversationTime {lastSeenConversationTime}. lastSeenConversationTime must be greater or equal to 0."); + } + + var result = await _messageStore.GetMessages( + conversationId, limit, orderBy, continuationToken, lastSeenConversationTime); + + return new GetMessagesResponse + { + messages = result.Messages, + nextContinuationToken = result.NextContinuationToken + }; } } \ No newline at end of file diff --git a/ChatService.Web/Storage/CosmosMessageStore.cs b/ChatService.Web/Storage/CosmosMessageStore.cs index 3cbd7ed..78b46e5 100644 --- a/ChatService.Web/Storage/CosmosMessageStore.cs +++ b/ChatService.Web/Storage/CosmosMessageStore.cs @@ -128,6 +128,14 @@ public async Task GetMessage(string conversationId, string messageId) return (messages, nextContinuationToken); } + public async Task ConversationPartitionExists(string conversationId) + { + var response = await GetMessages( + conversationId, 1, OrderBy.ASC, null, 0); + + return (response.Messages.Count > 0); + } + public async Task DeleteMessage(string conversationId, string messageId) { try diff --git a/ChatService.Web/Storage/IMessageStore.cs b/ChatService.Web/Storage/IMessageStore.cs index 878611d..109a884 100644 --- a/ChatService.Web/Storage/IMessageStore.cs +++ b/ChatService.Web/Storage/IMessageStore.cs @@ -9,5 +9,6 @@ public interface IMessageStore Task GetMessage(string conversationId, string messageId); Task<(List Messages, string NextContinuationToken)> GetMessages( string conversationId, int limit, OrderBy order, string? continuationToken, long lastSeenMessageTime); + Task ConversationPartitionExists(string conversationId); Task DeleteMessage(string conversationId, string messageId); } diff --git a/ChatService.sln.DotSettings.user b/ChatService.sln.DotSettings.user index 11a1edb..de57a75 100644 --- a/ChatService.sln.DotSettings.user +++ b/ChatService.sln.DotSettings.user @@ -4,10 +4,10 @@ On On C:\Users\nadim\AppData\Local\JetBrains\Rider2022.3\resharper-host\temp\Rider\vAny\CoverageData\_ChatService.34854384\Snapshot\snapshot.utdcvr - <SessionState ContinuousTestingMode="0" IsActive="True" Name="GetUserConversations_Ordered_Successful" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <SessionState ContinuousTestingMode="0" Name="GetUserConversations_Ordered_Successful" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> </SessionState> - <SessionState ContinuousTestingMode="0" Name="Session" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <SessionState ContinuousTestingMode="0" IsActive="True" Name="Session" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> </SessionState> From e019ded134a5f4bbc13e9c12276f60e66909dc9e Mon Sep 17 00:00:00 2001 From: Ali Date: Thu, 30 Mar 2023 17:41:37 +0300 Subject: [PATCH 31/45] Perform code refactor & implement ConversationsController --- .../CosmosConversationStoreTests.cs | 66 ++++---- .../CosmosMessageStoreTests.cs | 48 +++--- .../CosmosProfileStoreTests.cs | 24 +-- .../Controllers/ImageControllerTests.cs | 12 +- .../Controllers/ProfileControllerTests.cs | 26 ++-- .../Services/ProfileServiceTests.cs | 28 ++-- .../Controllers/ConversationsController.cs | 145 ++++++++++++++++++ ...ImageController.cs => ImagesController.cs} | 6 +- ...ileController.cs => ProfilesController.cs} | 8 +- ChatService.Web/Dtos/Conversation.cs | 7 +- .../Dtos/GetConversationsResponse.cs | 9 -- ChatService.Web/Dtos/GetMessagesResponse.cs | 4 +- .../Dtos/GetMessagesServiceResult.cs | 9 ++ .../Dtos/GetUserConversationsResponse.cs | 9 ++ .../Dtos/GetUserConversationsServiceResult.cs | 9 ++ ChatService.Web/Dtos/Message.cs | 9 +- ChatService.Web/Dtos/Profile.cs | 8 +- ChatService.Web/Dtos/SendMessageRequest.cs | 4 +- .../Dtos/StartConversationRequest.cs | 4 +- .../Dtos/StartConversationResponse.cs | 2 +- ChatService.Web/Dtos/UploadImageResponse.cs | 2 +- ChatService.Web/Dtos/UserConversation.cs | 8 +- .../ConversationDoesNotExistException.cs | 8 + .../ConversationPartitionDoesNotExist.cs | 8 - .../Exceptions/UserNotFoundException.cs | 8 + ChatService.Web/Program.cs | 4 +- ChatService.Web/Services/IMessageService.cs | 3 +- ...Service.cs => IUserConversationService.cs} | 4 +- ChatService.Web/Services/MessageService.cs | 38 +++-- ChatService.Web/Services/ProfileService.cs | 18 +-- ...nService.cs => UserConversationService.cs} | 81 +++++----- ChatService.Web/Storage/CosmosMessageStore.cs | 32 ++-- ChatService.Web/Storage/CosmosProfileStore.cs | 30 ++-- ...tore.cs => CosmosUserConversationStore.cs} | 32 ++-- .../Storage/Entities/MessageEntity.cs | 9 +- .../Storage/Entities/ProfileEntity.cs | 6 +- .../Entities/UserConversationEntity.cs | 5 +- ...tionStore.cs => IUserConversationStore.cs} | 2 +- ChatService.sln.DotSettings.user | 23 ++- 39 files changed, 478 insertions(+), 280 deletions(-) create mode 100644 ChatService.Web/Controllers/ConversationsController.cs rename ChatService.Web/Controllers/{ImageController.cs => ImagesController.cs} (91%) rename ChatService.Web/Controllers/{ProfileController.cs => ProfilesController.cs} (87%) delete mode 100644 ChatService.Web/Dtos/GetConversationsResponse.cs create mode 100644 ChatService.Web/Dtos/GetMessagesServiceResult.cs create mode 100644 ChatService.Web/Dtos/GetUserConversationsResponse.cs create mode 100644 ChatService.Web/Dtos/GetUserConversationsServiceResult.cs create mode 100644 ChatService.Web/Exceptions/ConversationDoesNotExistException.cs delete mode 100644 ChatService.Web/Exceptions/ConversationPartitionDoesNotExist.cs create mode 100644 ChatService.Web/Exceptions/UserNotFoundException.cs rename ChatService.Web/Services/{IConversationService.cs => IUserConversationService.cs} (73%) rename ChatService.Web/Services/{ConversationService.cs => UserConversationService.cs} (61%) rename ChatService.Web/Storage/{CosmosConversationStore.cs => CosmosUserConversationStore.cs} (84%) rename ChatService.Web/Storage/{IConversationStore.cs => IUserConversationStore.cs} (93%) diff --git a/ChatService.Web.IntegrationTests/CosmosConversationStoreTests.cs b/ChatService.Web.IntegrationTests/CosmosConversationStoreTests.cs index 1f6a437..12a44f5 100644 --- a/ChatService.Web.IntegrationTests/CosmosConversationStoreTests.cs +++ b/ChatService.Web.IntegrationTests/CosmosConversationStoreTests.cs @@ -9,39 +9,39 @@ namespace ChatService.Web.IntegrationTests; public class CosmosConversationStoreTests : IClassFixture>, IAsyncLifetime { - private readonly IConversationStore _store; + private readonly IUserConversationStore _store; private static readonly UserConversation _userConversation = new UserConversation { - username = Guid.NewGuid().ToString(), - conversationId = Guid.NewGuid().ToString(), - lastModifiedTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + Username = Guid.NewGuid().ToString(), + ConversationId = Guid.NewGuid().ToString(), + LastModifiedTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds() }; private readonly UserConversation _userConversation1 = new UserConversation { - username = _userConversation.username, - conversationId = Guid.NewGuid().ToString(), - lastModifiedTime = 100 + Username = _userConversation.Username, + ConversationId = Guid.NewGuid().ToString(), + LastModifiedTime = 100 }; private readonly UserConversation _userConversation2 = new UserConversation { - username = _userConversation.username, - conversationId = Guid.NewGuid().ToString(), - lastModifiedTime = 200 + Username = _userConversation.Username, + ConversationId = Guid.NewGuid().ToString(), + LastModifiedTime = 200 }; private readonly UserConversation _userConversation3 = new UserConversation { - username = _userConversation.username, - conversationId = Guid.NewGuid().ToString(), - lastModifiedTime = 300 + Username = _userConversation.Username, + ConversationId = Guid.NewGuid().ToString(), + LastModifiedTime = 300 }; public CosmosConversationStoreTests(WebApplicationFactory factory) { - _store = factory.Services.GetRequiredService(); + _store = factory.Services.GetRequiredService(); } [Fact] @@ -49,7 +49,7 @@ public async Task CreateUserConversation_Successful() { await _store.CreateUserConversation(_userConversation); - Assert.Equal(_userConversation, await _store.GetUserConversation(_userConversation.username, _userConversation.conversationId)); + Assert.Equal(_userConversation, await _store.GetUserConversation(_userConversation.Username, _userConversation.ConversationId)); } [Theory] @@ -64,9 +64,9 @@ public async Task CreateUserConversation_InvalidArguments(string username, strin { UserConversation userConversation = new() { - username = username, - conversationId = conversationId, - lastModifiedTime = lastModifiedTime + Username = username, + ConversationId = conversationId, + LastModifiedTime = lastModifiedTime }; await Assert.ThrowsAsync( @@ -99,7 +99,7 @@ await Assert.ThrowsAsync( public async Task GetUserConversation_ConversationNotFound() { await Assert.ThrowsAsync( - () => _store.GetUserConversation(_userConversation.username, _userConversation.conversationId)); + () => _store.GetUserConversation(_userConversation.Username, _userConversation.ConversationId)); } [Fact] @@ -107,13 +107,13 @@ public async Task GetUserConversations_Limit() { await AddMultipleUserConversations(_userConversation1, _userConversation2, _userConversation3); - var response = await _store.GetUserConversations(_userConversation.username, 1, OrderBy.ASC, null, 1); + var response = await _store.GetUserConversations(_userConversation.Username, 1, OrderBy.ASC, null, 1); Assert.Equal(1, response.UserConversations.Count); - response = await _store.GetUserConversations(_userConversation.username, 2, OrderBy.ASC, null, 1); + response = await _store.GetUserConversations(_userConversation.Username, 2, OrderBy.ASC, null, 1); Assert.Equal(2, response.UserConversations.Count); - response = await _store.GetUserConversations(_userConversation.username, 3, OrderBy.ASC, null, 1); + response = await _store.GetUserConversations(_userConversation.Username, 3, OrderBy.ASC, null, 1); Assert.Equal(3, response.UserConversations.Count); await DeleteMultipleUserConversations(_userConversation1, _userConversation2, _userConversation3); @@ -130,7 +130,7 @@ public async Task GetUserConversations_OrderBy(OrderBy orderBy) await AddMultipleUserConversations( _userConversation, _userConversation1, _userConversation2, _userConversation3); - var response = await _store.GetUserConversations(_userConversation.username, 10, orderBy, null, 0); + var response = await _store.GetUserConversations(_userConversation.Username, 10, orderBy, null, 0); if (orderBy == OrderBy.ASC) { @@ -151,20 +151,20 @@ public async Task GetUserConversations_ContinuationTokenValidity() await AddMultipleUserConversations(_userConversation1, _userConversation2, _userConversation3); var response = await _store.GetUserConversations( - _userConversation.username, 1, OrderBy.ASC, null, 1); + _userConversation.Username, 1, OrderBy.ASC, null, 1); Assert.Equal(_userConversation1, response.UserConversations.ElementAt(0)); var nextContinuation = response.NextContinuationToken; Assert.NotNull(nextContinuation); - response = await _store.GetUserConversations(_userConversation.username, 1, OrderBy.ASC, nextContinuation, 1); + response = await _store.GetUserConversations(_userConversation.Username, 1, OrderBy.ASC, nextContinuation, 1); Assert.Equal(_userConversation2, response.UserConversations.ElementAt(0)); nextContinuation = response.NextContinuationToken; Assert.NotNull(nextContinuation); - response = await _store.GetUserConversations(_userConversation.username, 1, OrderBy.ASC, nextContinuation, 1); + response = await _store.GetUserConversations(_userConversation.Username, 1, OrderBy.ASC, nextContinuation, 1); Assert.Equal(_userConversation3, response.UserConversations.ElementAt(0)); nextContinuation = response.NextContinuationToken; @@ -184,12 +184,12 @@ public async Task GetUserConversations_LastSeenConversationTime(long lastSeenCon List userConversationsExpected = new(); - if(_userConversation1.lastModifiedTime > lastSeenConversationTime) { userConversationsExpected.Add(_userConversation1); } - if(_userConversation2.lastModifiedTime > lastSeenConversationTime) { userConversationsExpected.Add(_userConversation2);} - if(_userConversation3.lastModifiedTime > lastSeenConversationTime) { userConversationsExpected.Add(_userConversation3);} - if(_userConversation.lastModifiedTime > lastSeenConversationTime) { userConversationsExpected.Add(_userConversation);} + if(_userConversation1.LastModifiedTime > lastSeenConversationTime) { userConversationsExpected.Add(_userConversation1); } + if(_userConversation2.LastModifiedTime > lastSeenConversationTime) { userConversationsExpected.Add(_userConversation2);} + if(_userConversation3.LastModifiedTime > lastSeenConversationTime) { userConversationsExpected.Add(_userConversation3);} + if(_userConversation.LastModifiedTime > lastSeenConversationTime) { userConversationsExpected.Add(_userConversation);} - var response = await _store.GetUserConversations(_userConversation.username, 10, OrderBy.ASC, null, lastSeenConversationTime); + var response = await _store.GetUserConversations(_userConversation.Username, 10, OrderBy.ASC, null, lastSeenConversationTime); Assert.Equal(userConversationsExpected, response.UserConversations); @@ -233,7 +233,7 @@ private async Task DeleteMultipleUserConversations(params UserConversation[] use { foreach (UserConversation userConversation in userConversations) { - await _store.DeleteUserConversation(userConversation.username, userConversation.conversationId); + await _store.DeleteUserConversation(userConversation.Username, userConversation.ConversationId); } } @@ -244,6 +244,6 @@ public Task InitializeAsync() public async Task DisposeAsync() { - await _store.DeleteUserConversation(_userConversation.username, _userConversation.conversationId); + await _store.DeleteUserConversation(_userConversation.Username, _userConversation.ConversationId); } } \ No newline at end of file diff --git a/ChatService.Web.IntegrationTests/CosmosMessageStoreTests.cs b/ChatService.Web.IntegrationTests/CosmosMessageStoreTests.cs index 053c09d..de6e4cc 100644 --- a/ChatService.Web.IntegrationTests/CosmosMessageStoreTests.cs +++ b/ChatService.Web.IntegrationTests/CosmosMessageStoreTests.cs @@ -15,26 +15,26 @@ public class CosmosMessageStoreTests : IClassFixture factory) @@ -47,7 +47,7 @@ public async Task AddMessage_Successful() { await _store.AddMessage(_conversationId, _message1); - Assert.Equal(_message1, await _store.GetMessage(_conversationId, _message1.id)); + Assert.Equal(_message1, await _store.GetMessage(_conversationId, _message1.MessageId)); } [Theory] @@ -65,10 +65,10 @@ public async Task AddMessage_InvalidArguments(string id, string senderUsername, { Message message = new Message { - id = id, - unixTime = unixTime, - senderUsername = senderUsername, - text = text + MessageId = id, + UnixTime = unixTime, + SenderUsername = senderUsername, + Text = text }; await Assert.ThrowsAsync(() => _store.AddMessage(_conversationId, message)); @@ -97,7 +97,7 @@ public async Task GetMessage_InvalidArguments(string conversationId, string mess [Fact] public async Task GetMessage_MessageNotFound() { - await Assert.ThrowsAsync(() => _store.GetMessage(_conversationId, _message1.id)); + await Assert.ThrowsAsync(() => _store.GetMessage(_conversationId, _message1.MessageId)); } [Fact] @@ -142,7 +142,7 @@ public async Task GetMessages_OrderBy(OrderBy orderBy) Assert.Equal(messagesExpected, response.Messages); } - await _store.DeleteMessage(_conversationId, _message2.id); + await _store.DeleteMessage(_conversationId, _message2.MessageId); } [Fact] @@ -178,9 +178,9 @@ public async Task GetMessages_LastSeenMessageTime(long lastSeenMessageTime) await AddMultipleMessages(_conversationId, _message1, _message2, _message3); List messagesExpected = new(); - if(_message1.unixTime > lastSeenMessageTime) messagesExpected.Add(_message1); - if(_message2.unixTime > lastSeenMessageTime) messagesExpected.Add(_message2); - if(_message3.unixTime > lastSeenMessageTime) messagesExpected.Add(_message3); + if(_message1.UnixTime > lastSeenMessageTime) messagesExpected.Add(_message1); + if(_message2.UnixTime > lastSeenMessageTime) messagesExpected.Add(_message2); + if(_message3.UnixTime > lastSeenMessageTime) messagesExpected.Add(_message3); var response = await _store.GetMessages( _conversationId, 10, OrderBy.ASC, null, lastSeenMessageTime); @@ -229,7 +229,7 @@ private async Task DeleteMultipleMessages(string conversationId, params Message[ { foreach (Message message in messages) { - await _store.DeleteMessage(conversationId, message.id); + await _store.DeleteMessage(conversationId, message.MessageId); } } @@ -240,6 +240,6 @@ public Task InitializeAsync() public async Task DisposeAsync() { - await _store.DeleteMessage(_conversationId, _message1.id); + await _store.DeleteMessage(_conversationId, _message1.MessageId); } } \ No newline at end of file diff --git a/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs b/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs index aadf0fc..b87ff6f 100644 --- a/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs +++ b/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs @@ -11,10 +11,10 @@ public class CosmosProfileStoreTest : IClassFixture factory) @@ -36,7 +36,7 @@ public CosmosProfileStoreTest(WebApplicationFactory factory) public async Task AddNewProfile_Success() { await _store.AddProfile(_profile); - Assert.Equal(_profile, await _store.GetProfile(_profile.username)); + Assert.Equal(_profile, await _store.GetProfile(_profile.Username)); } [Theory] @@ -74,28 +74,28 @@ public async Task AddNewProfile_UsernameTaken() [Fact] public async Task GetNonExistingProfile() { - Assert.Null(await _store.GetProfile(_profile.username)); + Assert.Null(await _store.GetProfile(_profile.Username)); } [Fact] public async Task DeleteProfile() { await _store.AddProfile(_profile); - Assert.Equal(_profile, await _store.GetProfile(_profile.username)); - await _store.DeleteProfile(_profile.username); - Assert.Null(await _store.GetProfile(_profile.username)); + Assert.Equal(_profile, await _store.GetProfile(_profile.Username)); + await _store.DeleteProfile(_profile.Username); + Assert.Null(await _store.GetProfile(_profile.Username)); } [Fact] public async Task ProfileExists_Exists() { await _store.AddProfile(_profile); - Assert.True(await _store.ProfileExists(_profile.username)); + Assert.True(await _store.ProfileExists(_profile.Username)); } [Fact] public async Task ProfileExists_DoesNotExist() { - Assert.False(await _store.ProfileExists(_profile.username)); + Assert.False(await _store.ProfileExists(_profile.Username)); } } \ No newline at end of file diff --git a/ChatService.Web.Tests/Controllers/ImageControllerTests.cs b/ChatService.Web.Tests/Controllers/ImageControllerTests.cs index bb9b3e9..0981031 100644 --- a/ChatService.Web.Tests/Controllers/ImageControllerTests.cs +++ b/ChatService.Web.Tests/Controllers/ImageControllerTests.cs @@ -40,11 +40,11 @@ public async Task UploadImage_Success() fileContent.Headers.ContentType = new MediaTypeHeaderValue(image.ContentType); _content.Add(fileContent,"File", "image.jpeg"); - var response = await _httpClient.PostAsync("/Image", _content); + var response = await _httpClient.PostAsync("api/Images/", _content); Assert.Equal(HttpStatusCode.Created, response.StatusCode); - Assert.Equal($"http://localhost/Image/{imageId}", response.Headers.GetValues("Location").First()); + Assert.Equal($"http://localhost/api/Images/{imageId}", response.Headers.GetValues("Location").First()); var json = await response.Content.ReadAsStringAsync(); var receivedUploadImageResponse = JsonConvert.DeserializeObject(json); @@ -54,7 +54,7 @@ public async Task UploadImage_Success() [Fact] public async Task UploadImage_MissingFile() { - var response = await _httpClient.PostAsync("/Image", _content); + var response = await _httpClient.PostAsync("api/Images/", _content); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); @@ -68,7 +68,7 @@ public async Task UploadImage_InvalidFile() fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain"); _content.Add(fileContent,"File", "file.txt"); - var response = await _httpClient.PostAsync("/Image", _content); + var response = await _httpClient.PostAsync("api/Images/", _content); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); @@ -88,7 +88,7 @@ public async Task DownloadImage_Success() _imageStoreMock.Setup(m => m.DownloadImage(imageId)) .ReturnsAsync(image); - var response = await _httpClient.GetAsync($"/Image/{imageId}"); + var response = await _httpClient.GetAsync($"api/Images/{imageId}"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -104,7 +104,7 @@ public async Task DownloadImage_NotFound() { var imageId = Guid.NewGuid().ToString(); - var response = await _httpClient.GetAsync($"/Image/{imageId}"); + var response = await _httpClient.GetAsync($"api/Images/{imageId}"); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } diff --git a/ChatService.Web.Tests/Controllers/ProfileControllerTests.cs b/ChatService.Web.Tests/Controllers/ProfileControllerTests.cs index f8a1fdc..f590907 100644 --- a/ChatService.Web.Tests/Controllers/ProfileControllerTests.cs +++ b/ChatService.Web.Tests/Controllers/ProfileControllerTests.cs @@ -31,10 +31,10 @@ public ProfileControllerTests(WebApplicationFactory factory) [Fact] public async Task GetProfile_Success() { - _profileServiceMock.Setup(m => m.GetProfile(_profile.username)) + _profileServiceMock.Setup(m => m.GetProfile(_profile.Username)) .ReturnsAsync(_profile); - var response = await _httpClient.GetAsync($"/Profile/{_profile.username}"); + var response = await _httpClient.GetAsync($"api/Profiles/{_profile.Username}"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -46,25 +46,25 @@ public async Task GetProfile_Success() [Fact] public async Task GetProfile_ProfileNotFound() { - _profileServiceMock.Setup(m => m.GetProfile(_profile.username)) + _profileServiceMock.Setup(m => m.GetProfile(_profile.Username)) .ReturnsAsync((Profile?) null); - var response = await _httpClient.GetAsync($"/Profile/{_profile.username}"); + var response = await _httpClient.GetAsync($"api/Profiles/{_profile.Username}"); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); var json = await response.Content.ReadAsStringAsync(); - Assert.Equal($"A profile with the username {_profile.username} was not found.", json); + Assert.Equal($"A profile with the username {_profile.Username} was not found.", json); } [Fact] public async Task PostProfile_Success() { - var response = await _httpClient.PostAsJsonAsync("/Profile/", _profile); + var response = await _httpClient.PostAsJsonAsync("api/Profiles/", _profile); Assert.Equal(HttpStatusCode.Created, response.StatusCode); - Assert.Equal($"http://localhost/Profile/{_profile.username}", + Assert.Equal($"http://localhost/api/Profiles/{_profile.Username}", response.Headers.GetValues("Location").First()); var json = await response.Content.ReadAsStringAsync(); @@ -78,9 +78,9 @@ public async Task PostProfile_Success() public async Task PostProfile_UsernameTaken() { _profileServiceMock.Setup(m => m.AddProfile(_profile)) - .ThrowsAsync(new UsernameTakenException($"The username {_profile.username} is taken.")); + .ThrowsAsync(new UsernameTakenException($"The username {_profile.Username} is taken.")); - var response = await _httpClient.PostAsJsonAsync("/Profile/", _profile); + var response = await _httpClient.PostAsJsonAsync("api/Profiles/", _profile); Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); } @@ -105,7 +105,7 @@ public async Task PostProfile_InvalidArguments(string username, string firstName Profile profile = new(username, firstName, lastName, profilePictureId); - var response = await _httpClient.PostAsJsonAsync("/Profile", profile); + var response = await _httpClient.PostAsJsonAsync("api/Profiles/", profile); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } @@ -116,9 +116,9 @@ public async Task PostProfile_InvalidUsername() Profile profile = new("username_with_underscore", "firstName", "lastName", "profilePictureId"); _profileServiceMock.Setup(m => m.AddProfile(profile)) - .ThrowsAsync(new InvalidUsernameException($"Username {profile.username} is invalid. Usernames cannot have an underscore.")); + .ThrowsAsync(new InvalidUsernameException($"Username {profile.Username} is invalid. Usernames cannot have an underscore.")); - var response = await _httpClient.PostAsJsonAsync("/Profile", profile); + var response = await _httpClient.PostAsJsonAsync("api/Profiles/", profile); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } @@ -129,7 +129,7 @@ public async Task ProfileProfile_ProfilePictureNotFound() _profileServiceMock.Setup(m => m.AddProfile(_profile)) .ThrowsAsync(new ImageNotFoundException("Invalid profile picture ID.")); - var response = await _httpClient.PostAsJsonAsync("/Profile/", _profile); + var response = await _httpClient.PostAsJsonAsync("api/Profiles/", _profile); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } diff --git a/ChatService.Web.Tests/Services/ProfileServiceTests.cs b/ChatService.Web.Tests/Services/ProfileServiceTests.cs index 31fed8c..885819c 100644 --- a/ChatService.Web.Tests/Services/ProfileServiceTests.cs +++ b/ChatService.Web.Tests/Services/ProfileServiceTests.cs @@ -32,10 +32,10 @@ public ProfileServiceTests(WebApplicationFactory factory) [Fact] public async Task GetProfile_Success() { - _profileStoreMock.Setup(m => m.GetProfile(_profile.username)) + _profileStoreMock.Setup(m => m.GetProfile(_profile.Username)) .ReturnsAsync(_profile); - var receivedProfile = await _profileService.GetProfile(_profile.username); + var receivedProfile = await _profileService.GetProfile(_profile.Username); Assert.Equal(_profile, receivedProfile); } @@ -52,9 +52,9 @@ public async Task GetProfile_InvalidArguments(string username) [Fact] public async Task AddNewProfile_Success() { - _profileStoreMock.Setup(m => m.ProfileExists(_profile.username)) + _profileStoreMock.Setup(m => m.ProfileExists(_profile.Username)) .ReturnsAsync(false); - _imageStoreMock.Setup(m => m.ImageExists(_profile.profilePictureId)) + _imageStoreMock.Setup(m => m.ImageExists(_profile.ProfilePictureId)) .ReturnsAsync(true); await _profileService.AddProfile(_profile); @@ -90,10 +90,10 @@ public async Task AddNewProfile_InvalidArgs(string username, string firstName, s [Fact] public async Task AddNewProfile_UsernameTaken() { - _imageStoreMock.Setup(m => m.ImageExists(_profile.profilePictureId)) + _imageStoreMock.Setup(m => m.ImageExists(_profile.ProfilePictureId)) .ReturnsAsync(true); _profileStoreMock.Setup(m => m.AddProfile(_profile)) - .ThrowsAsync(new UsernameTakenException($"A profile with username {_profile.username} already exists.")); + .ThrowsAsync(new UsernameTakenException($"A profile with username {_profile.Username} already exists.")); await Assert.ThrowsAsync( async () => await _profileService.AddProfile(_profile)); } @@ -108,7 +108,7 @@ public async Task AddNewProfile_InvalidUsername() [Fact] public async Task AddNewProfile_ProfilePictureNotFound() { - _imageStoreMock.Setup(m => m.ImageExists(_profile.profilePictureId)) + _imageStoreMock.Setup(m => m.ImageExists(_profile.ProfilePictureId)) .ReturnsAsync(false); await Assert.ThrowsAsync( async () => await _profileService.AddProfile(_profile)); @@ -117,22 +117,22 @@ public async Task AddNewProfile_ProfilePictureNotFound() [Fact] public async Task DeleteProfile_Success() { - _profileStoreMock.Setup(m => m.GetProfile(_profile.username)) + _profileStoreMock.Setup(m => m.GetProfile(_profile.Username)) .ReturnsAsync(_profile); - await _profileService.DeleteProfile(_profile.username); + await _profileService.DeleteProfile(_profile.Username); - _imageStoreMock.Verify(m => m.DeleteImage(_profile.profilePictureId), Times.Once); - _profileStoreMock.Verify(m => m.DeleteProfile(_profile.username), Times.Once); + _imageStoreMock.Verify(m => m.DeleteImage(_profile.ProfilePictureId), Times.Once); + _profileStoreMock.Verify(m => m.DeleteProfile(_profile.Username), Times.Once); } [Fact] public async Task DeleteProfile_ProfileNotFound() { await Assert.ThrowsAsync( - async () => await _profileService.DeleteProfile(_profile.username)); + async () => await _profileService.DeleteProfile(_profile.Username)); - _imageStoreMock.Verify(m => m.DeleteImage(_profile.profilePictureId), Times.Never); - _profileStoreMock.Verify(m => m.DeleteProfile(_profile.username), Times.Never); + _imageStoreMock.Verify(m => m.DeleteImage(_profile.ProfilePictureId), Times.Never); + _profileStoreMock.Verify(m => m.DeleteProfile(_profile.Username), Times.Never); } } \ No newline at end of file diff --git a/ChatService.Web/Controllers/ConversationsController.cs b/ChatService.Web/Controllers/ConversationsController.cs new file mode 100644 index 0000000..484790e --- /dev/null +++ b/ChatService.Web/Controllers/ConversationsController.cs @@ -0,0 +1,145 @@ +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; + + public ConversationsController(IUserConversationService userConversationService, IMessageService messageService) + { + _userConversationService = userConversationService; + _messageService = messageService; + } + + [HttpGet] + public async Task> GetUserConversations(string username, + int limit = 10, OrderBy orderBy = OrderBy.DESC, string? continuationToken = null, long lastSeenConversationTime = 0) + { + GetUserConversationsServiceResult result; + + try + { + result = await _userConversationService.GetUserConversations( + username, limit, orderBy, continuationToken, lastSeenConversationTime); + } + catch (ArgumentException e) + { + return BadRequest(e.Message); + } + catch (UserNotFoundException e) + { + return NotFound(e.Message); + } + + string nextUri = "/api/conversations" + + $"?username={username}" + + $"&limit={limit}" + + $"&lastSeenConversationTime={lastSeenConversationTime}" + + $"&continuationToken={result.NextContinuationToken}"; + + GetUserConversationsResponse response = new GetUserConversationsResponse + { + Conversations = result.Conversations, + NextUri = nextUri + }; + + return Ok(response); + } + + [HttpPost] + public async Task> StartConversation(StartConversationRequest request) + { + StartConversationResponse response; + + try + { + response = await _userConversationService.CreateConversation(request); + } + catch (ArgumentException e) + { + return BadRequest(e.Message); + } + catch (ProfileNotFoundException e) + { + return NotFound(e.Message); + } + catch (MessageExistsException e) + { + return Conflict(e.Message); + } + + return CreatedAtAction(nameof(GetUserConversations), + new { username = request.FirstMessage.SenderUsername }, response); + } + + [HttpGet("{conversationId}/messages")] + public async Task> GetMessages(string conversationId, + int limit = 10, OrderBy orderBy = OrderBy.DESC, string? continuationToken = null, long lastSeenConversationTime = 0) + { + GetMessagesServiceResult result; + + try + { + result = await _messageService.GetMessages( + conversationId, limit, orderBy, continuationToken, lastSeenConversationTime); + } + catch (ArgumentException e) + { + return BadRequest(e.Message); + } + catch (ConversationDoesNotExistException e) + { + return NotFound(e.Message); + } + + string nextUri = $"/api/conversations/{conversationId}/messages" + + $"&limit={limit}" + + $"&continuationToken={result.NextContinuationToken}" + + $"&lastSeenConversationTime={lastSeenConversationTime}"; + + GetMessagesResponse response = new GetMessagesResponse + { + Messages = result.Messages, + NextUri = nextUri + }; + + return Ok(response); + } + + [HttpPost("{conversationId}/messages")] + public async Task> PostMessage(string conversationId, SendMessageRequest request) + { + SendMessageResponse response; + + try + { + response = await _messageService.AddMessage(conversationId, false, request); + } + catch (ArgumentException e) + { + return BadRequest(e.Message); + } + catch (UserNotParticipantException e) + { + return new ObjectResult(e.Message) { StatusCode = 403 }; + } + catch (Exception e) when (e is ProfileNotFoundException || e is ConversationDoesNotExistException) + { + return NotFound(e.Message); + } + catch (MessageExistsException e) + { + return Conflict(e.Message); + } + + return CreatedAtAction(nameof(GetMessages), new { conversationId = conversationId}, response); + } +} \ No newline at end of file diff --git a/ChatService.Web/Controllers/ImageController.cs b/ChatService.Web/Controllers/ImagesController.cs similarity index 91% rename from ChatService.Web/Controllers/ImageController.cs rename to ChatService.Web/Controllers/ImagesController.cs index d04039f..ad14795 100644 --- a/ChatService.Web/Controllers/ImageController.cs +++ b/ChatService.Web/Controllers/ImagesController.cs @@ -5,12 +5,12 @@ namespace ChatService.Web.Controllers; [ApiController] -[Route("[controller]")] -public class ImageController : ControllerBase +[Route("api/[controller]")] +public class ImagesController : ControllerBase { private readonly IImageStore _imageStore; - public ImageController(IImageStore imageStore) + public ImagesController(IImageStore imageStore) { _imageStore = imageStore; } diff --git a/ChatService.Web/Controllers/ProfileController.cs b/ChatService.Web/Controllers/ProfilesController.cs similarity index 87% rename from ChatService.Web/Controllers/ProfileController.cs rename to ChatService.Web/Controllers/ProfilesController.cs index 8e38bab..56880ea 100644 --- a/ChatService.Web/Controllers/ProfileController.cs +++ b/ChatService.Web/Controllers/ProfilesController.cs @@ -6,12 +6,12 @@ namespace ChatService.Web.Controllers; [ApiController] -[Route("[controller]")] -public class ProfileController : ControllerBase +[Route("api/[controller]")] +public class ProfilesController : ControllerBase { private readonly IProfileService _profileService; - public ProfileController(IProfileService profileService) + public ProfilesController(IProfileService profileService) { _profileService = profileService; } @@ -34,7 +34,7 @@ public async Task> PostProfile(Profile profile) try { await _profileService.AddProfile(profile); - return CreatedAtAction(nameof(GetProfile), new { username = profile.username }, profile); + return CreatedAtAction(nameof(GetProfile), new { username = profile.Username }, profile); } catch (Exception e) when (e is ArgumentException || e is ImageNotFoundException || e is InvalidUsernameException) { diff --git a/ChatService.Web/Dtos/Conversation.cs b/ChatService.Web/Dtos/Conversation.cs index b6a6338..233d3bd 100644 --- a/ChatService.Web/Dtos/Conversation.cs +++ b/ChatService.Web/Dtos/Conversation.cs @@ -1,11 +1,10 @@ using System.ComponentModel.DataAnnotations; -using Microsoft.Azure.Cosmos.Serialization.HybridRow; 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; } + [Required] public string ConversationId { get; set; } + [Required] public long LastModifiedUnixTime { get; set; } + [Required] public Profile Recipient { get; set; } } \ No newline at end of file diff --git a/ChatService.Web/Dtos/GetConversationsResponse.cs b/ChatService.Web/Dtos/GetConversationsResponse.cs deleted file mode 100644 index 1ee0917..0000000 --- a/ChatService.Web/Dtos/GetConversationsResponse.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace ChatService.Web.Dtos; - -public record GetConversationsResponse -{ - [Required] public List conversations { get; set; } - [Required] public string nextContinuationToken { get; set; } -} \ No newline at end of file diff --git a/ChatService.Web/Dtos/GetMessagesResponse.cs b/ChatService.Web/Dtos/GetMessagesResponse.cs index c75b2d3..e87df01 100644 --- a/ChatService.Web/Dtos/GetMessagesResponse.cs +++ b/ChatService.Web/Dtos/GetMessagesResponse.cs @@ -4,6 +4,6 @@ namespace ChatService.Web.Dtos; public record GetMessagesResponse { - [Required] public List messages { get; set; } - [Required] public string nextContinuationToken { get; set; } + [Required] public List Messages { get; set; } + [Required] public string NextUri { get; set; } } \ No newline at end of file diff --git a/ChatService.Web/Dtos/GetMessagesServiceResult.cs b/ChatService.Web/Dtos/GetMessagesServiceResult.cs new file mode 100644 index 0000000..292dbb2 --- /dev/null +++ b/ChatService.Web/Dtos/GetMessagesServiceResult.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatService.Web.Dtos; + +public record GetMessagesServiceResult +{ + [Required] public List Messages { get; set; } + [Required] public string NextContinuationToken { get; set; } +} \ No newline at end of file diff --git a/ChatService.Web/Dtos/GetUserConversationsResponse.cs b/ChatService.Web/Dtos/GetUserConversationsResponse.cs new file mode 100644 index 0000000..5b9d11e --- /dev/null +++ b/ChatService.Web/Dtos/GetUserConversationsResponse.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatService.Web.Dtos; + +public record GetUserConversationsResponse +{ + [Required] public List Conversations { get; set; } + [Required] public string NextUri { get; set; } +} \ No newline at end of file diff --git a/ChatService.Web/Dtos/GetUserConversationsServiceResult.cs b/ChatService.Web/Dtos/GetUserConversationsServiceResult.cs new file mode 100644 index 0000000..4cc1d72 --- /dev/null +++ b/ChatService.Web/Dtos/GetUserConversationsServiceResult.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatService.Web.Dtos; + +public record GetUserConversationsServiceResult +{ + [Required] public List Conversations { get; set; } + [Required] public string NextContinuationToken { get; set; } +} \ No newline at end of file diff --git a/ChatService.Web/Dtos/Message.cs b/ChatService.Web/Dtos/Message.cs index e0e0aa8..b872f94 100644 --- a/ChatService.Web/Dtos/Message.cs +++ b/ChatService.Web/Dtos/Message.cs @@ -1,12 +1,11 @@ using System.ComponentModel.DataAnnotations; -using Microsoft.Azure.Cosmos.Serialization.HybridRow; 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; } + [Required] public string MessageId { get; set; } + [Required] public long UnixTime { get; set; } + [Required] public string SenderUsername { get; set; } + [Required] public string Text { get; set; } }; \ No newline at end of file diff --git a/ChatService.Web/Dtos/Profile.cs b/ChatService.Web/Dtos/Profile.cs index b1fc984..ab74341 100644 --- a/ChatService.Web/Dtos/Profile.cs +++ b/ChatService.Web/Dtos/Profile.cs @@ -3,7 +3,7 @@ namespace ChatService.Web.Dtos; public record Profile( - [Required] string username, - [Required] string firstName, - [Required] string lastName, - [Required] string profilePictureId); \ No newline at end of file + [Required] string Username, + [Required] string FirstName, + [Required] string LastName, + [Required] string ProfilePictureId); \ No newline at end of file diff --git a/ChatService.Web/Dtos/SendMessageRequest.cs b/ChatService.Web/Dtos/SendMessageRequest.cs index 746f989..e6d8e6f 100644 --- a/ChatService.Web/Dtos/SendMessageRequest.cs +++ b/ChatService.Web/Dtos/SendMessageRequest.cs @@ -4,7 +4,7 @@ namespace ChatService.Web.Dtos; public record SendMessageRequest { - [Required] public string id { get; set; } + [Required] public string MessageId { get; set; } [Required] public string SenderUsername { get; set; } - [Required] public string text { get; set; } + [Required] public string Text { get; set; } } \ No newline at end of file diff --git a/ChatService.Web/Dtos/StartConversationRequest.cs b/ChatService.Web/Dtos/StartConversationRequest.cs index ed223f9..27432ec 100644 --- a/ChatService.Web/Dtos/StartConversationRequest.cs +++ b/ChatService.Web/Dtos/StartConversationRequest.cs @@ -4,6 +4,6 @@ namespace ChatService.Web.Dtos; public record StartConversationRequest { - [Required] public List participants { get; set; } - [Required] public SendMessageRequest firstMessage { get; set; } + [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 index 2648a5d..8d81daa 100644 --- a/ChatService.Web/Dtos/StartConversationResponse.cs +++ b/ChatService.Web/Dtos/StartConversationResponse.cs @@ -4,6 +4,6 @@ namespace ChatService.Web.Dtos; public record StartConversationResponse { - [Required] public string Id { get; set; } + [Required] public string ConversationId { get; set; } [Required] public long CreatedUnixTime { get; set; } } \ No newline at end of file diff --git a/ChatService.Web/Dtos/UploadImageResponse.cs b/ChatService.Web/Dtos/UploadImageResponse.cs index 9989ef4..a95c54e 100644 --- a/ChatService.Web/Dtos/UploadImageResponse.cs +++ b/ChatService.Web/Dtos/UploadImageResponse.cs @@ -1,4 +1,4 @@ namespace ChatService.Web.Dtos; public record UploadImageResponse( - string imageId); \ No newline at end of file + string ImageId); \ No newline at end of file diff --git a/ChatService.Web/Dtos/UserConversation.cs b/ChatService.Web/Dtos/UserConversation.cs index 3b8192b..72b379a 100644 --- a/ChatService.Web/Dtos/UserConversation.cs +++ b/ChatService.Web/Dtos/UserConversation.cs @@ -1,10 +1,8 @@ -using Microsoft.Azure.Cosmos.Serialization.HybridRow; - namespace ChatService.Web.Dtos; public record UserConversation { - public string username { get; set; } - public string conversationId { get; set; } - public long lastModifiedTime { get; set; } + 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/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/ConversationPartitionDoesNotExist.cs b/ChatService.Web/Exceptions/ConversationPartitionDoesNotExist.cs deleted file mode 100644 index 1cc7463..0000000 --- a/ChatService.Web/Exceptions/ConversationPartitionDoesNotExist.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace ChatService.Web.Exceptions; - -public class ConversationPartitionDoesNotExist : Exception -{ - public ConversationPartitionDoesNotExist(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/Program.cs b/ChatService.Web/Program.cs index 2adf257..0d80c49 100644 --- a/ChatService.Web/Program.cs +++ b/ChatService.Web/Program.cs @@ -14,7 +14,7 @@ // Add services to the container. builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => @@ -29,6 +29,8 @@ } ); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddControllers(); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle diff --git a/ChatService.Web/Services/IMessageService.cs b/ChatService.Web/Services/IMessageService.cs index 73c6e84..70ca87a 100644 --- a/ChatService.Web/Services/IMessageService.cs +++ b/ChatService.Web/Services/IMessageService.cs @@ -6,6 +6,7 @@ namespace ChatService.Web.Services; public interface IMessageService { Task AddMessage(string conversationId, bool isFirstMessage, SendMessageRequest request); - Task GetMessages( + Task AddFirstMessage(string conversationId, SendMessageRequest request); + Task GetMessages( string conversationId, int limit, OrderBy orderBy, string? continuationToken, long lastSeenConversationTime); } \ No newline at end of file diff --git a/ChatService.Web/Services/IConversationService.cs b/ChatService.Web/Services/IUserConversationService.cs similarity index 73% rename from ChatService.Web/Services/IConversationService.cs rename to ChatService.Web/Services/IUserConversationService.cs index 9f25ff0..ea3f0c7 100644 --- a/ChatService.Web/Services/IConversationService.cs +++ b/ChatService.Web/Services/IUserConversationService.cs @@ -3,9 +3,9 @@ namespace ChatService.Web.Services; -public interface IConversationService +public interface IUserConversationService { Task CreateConversation(StartConversationRequest request); - Task GetConversations( + Task GetUserConversations( string username, int limit, OrderBy orderBy, string? continuationToken, long lastSeenConversationTime); } \ No newline at end of file diff --git a/ChatService.Web/Services/MessageService.cs b/ChatService.Web/Services/MessageService.cs index 53752de..3a2d26f 100644 --- a/ChatService.Web/Services/MessageService.cs +++ b/ChatService.Web/Services/MessageService.cs @@ -9,23 +9,20 @@ public class MessageService : IMessageService { private readonly IMessageStore _messageStore; private readonly IProfileService _profileService; - private readonly IConversationService _conversationService; - public MessageService(IMessageStore messageStore, IProfileService profileService, - IConversationService conversationService) + public MessageService(IMessageStore messageStore, IProfileService profileService) { _messageStore = messageStore; _profileService = profileService; - _conversationService = conversationService; } public async Task AddMessage(string conversationId, bool isFirstMessage, SendMessageRequest request) { if (request == null || - string.IsNullOrEmpty(request.id) || + string.IsNullOrEmpty(request.MessageId) || string.IsNullOrEmpty(request.SenderUsername) || - string.IsNullOrEmpty(request.text) + string.IsNullOrEmpty(request.Text) ) { throw new ArgumentException($"Invalid SendMessageRequest {request}."); @@ -54,7 +51,7 @@ public async Task AddMessage(string conversationId, bool is //if this is NOT the first message, check if the conversation already exists if (!isFirstMessage && !await _messageStore.ConversationPartitionExists(conversationId)) { - throw new ConversationPartitionDoesNotExist( + throw new ConversationDoesNotExistException( $"A conversation partition with the conversationId {conversationId} does not exist."); } //if it IS the first message, then its ok if the conversation does not exist as the partition will be created @@ -64,10 +61,10 @@ public async Task AddMessage(string conversationId, bool is Message message = new Message { - id = request.id, - unixTime = unixTimeNow, - senderUsername = request.SenderUsername, - text = request.text + MessageId = request.MessageId, + UnixTime = unixTimeNow, + SenderUsername = request.SenderUsername, + Text = request.Text }; await _messageStore.AddMessage(conversationId, message); @@ -79,7 +76,12 @@ public async Task AddMessage(string conversationId, bool is }; } - public async Task GetMessages(string conversationId, int limit, OrderBy orderBy, + public async Task AddFirstMessage(string conversationId, SendMessageRequest request) + { + return await AddMessage(conversationId, true, request); + } + + public async Task GetMessages(string conversationId, int limit, OrderBy orderBy, string? continuationToken, long lastSeenConversationTime) { if (string.IsNullOrEmpty(conversationId)) @@ -98,13 +100,19 @@ public async Task GetMessages(string conversationId, int li $"Invalid lastSeenConversationTime {lastSeenConversationTime}. lastSeenConversationTime must be greater or equal to 0."); } + if (!await _messageStore.ConversationPartitionExists(conversationId)) + { + throw new ConversationDoesNotExistException( + $"A conversation partition with the conversationId {conversationId} does not exist."); + } + var result = await _messageStore.GetMessages( conversationId, limit, orderBy, continuationToken, lastSeenConversationTime); - return new GetMessagesResponse + return new GetMessagesServiceResult { - messages = result.Messages, - nextContinuationToken = result.NextContinuationToken + Messages = result.Messages, + NextContinuationToken = result.NextContinuationToken }; } } \ No newline at end of file diff --git a/ChatService.Web/Services/ProfileService.cs b/ChatService.Web/Services/ProfileService.cs index 9b5b1d0..04dbaf3 100644 --- a/ChatService.Web/Services/ProfileService.cs +++ b/ChatService.Web/Services/ProfileService.cs @@ -28,25 +28,25 @@ public ProfileService(IProfileStore profileStore, IImageStore imageStore) public async Task AddProfile(Profile profile) { if (profile == null || - string.IsNullOrWhiteSpace(profile.username) || - string.IsNullOrWhiteSpace(profile.firstName) || - string.IsNullOrWhiteSpace(profile.lastName) || - string.IsNullOrWhiteSpace(profile.profilePictureId) + string.IsNullOrWhiteSpace(profile.Username) || + string.IsNullOrWhiteSpace(profile.FirstName) || + string.IsNullOrWhiteSpace(profile.LastName) || + string.IsNullOrWhiteSpace(profile.ProfilePictureId) ) { throw new ArgumentException($"Invalid profile {profile}", nameof(profile)); } - if (profile.username.Contains('_')) + if (profile.Username.Contains('_')) { - throw new InvalidUsernameException($"Username {profile.username} is invalid. Usernames cannot have an underscore."); + throw new InvalidUsernameException($"Username {profile.Username} is invalid. Usernames cannot have an underscore."); } - bool imageExists = await _imageStore.ImageExists(profile.profilePictureId); + bool imageExists = await _imageStore.ImageExists(profile.ProfilePictureId); if (!imageExists) { throw new ImageNotFoundException( - $"Profile picture with ID {profile.profilePictureId} was not found."); + $"Profile picture with ID {profile.ProfilePictureId} was not found."); } await _profileStore.AddProfile(profile); @@ -69,7 +69,7 @@ public async Task DeleteProfile(string username) { throw new ArgumentException($"Profile with username {username} doesn't exist."); } - await _imageStore.DeleteImage(profile.profilePictureId); + await _imageStore.DeleteImage(profile.ProfilePictureId); await _profileStore.DeleteProfile(username); } } \ No newline at end of file diff --git a/ChatService.Web/Services/ConversationService.cs b/ChatService.Web/Services/UserConversationService.cs similarity index 61% rename from ChatService.Web/Services/ConversationService.cs rename to ChatService.Web/Services/UserConversationService.cs index 559a16b..2f8a70e 100644 --- a/ChatService.Web/Services/ConversationService.cs +++ b/ChatService.Web/Services/UserConversationService.cs @@ -5,17 +5,17 @@ namespace ChatService.Web.Services; -public class ConversationService : IConversationService +public class UserConversationService : IUserConversationService { private readonly IMessageService _messageService; - private readonly IConversationStore _conversationStore; + private readonly IUserConversationStore _userConversationStore; private readonly IProfileService _profileService; - public ConversationService(IMessageService messageService, IConversationStore conversationStore, IProfileService profileService) + public UserConversationService(IMessageService messageService, IUserConversationStore userConversationStore, IProfileService profileService) { _messageService = messageService; - _conversationStore = conversationStore; + _userConversationStore = userConversationStore; _profileService = profileService; } @@ -26,25 +26,25 @@ public async Task CreateConversation(StartConversatio throw new ArgumentException($"StartConversationRequest is null."); } - if (request.participants.Count < 2 || + if (request.Participants.Count < 2 || //TODO: - string.IsNullOrEmpty(request.participants.ElementAt(0)) || //WRITE A TEST TO SEE THE PYTHON THING!!!!!!!!!!!!!! - string.IsNullOrEmpty(request.participants.ElementAt(1)) || - request.participants.ElementAt(0).Equals(request.participants.ElementAt(1))) + string.IsNullOrEmpty(request.Participants.ElementAt(0)) || //WRITE A TEST TO SEE THE PYTHON THING!!!!!!!!!!!!!! + string.IsNullOrEmpty(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"); + $"Invalid participants list ${request.Participants}. There must be 2 unique participant usernames"); } - if (string.IsNullOrEmpty(request.firstMessage.id) || - string.IsNullOrEmpty(request.firstMessage.SenderUsername) || - string.IsNullOrEmpty(request.firstMessage.text)) + if (string.IsNullOrEmpty(request.FirstMessage.MessageId) || + string.IsNullOrEmpty(request.FirstMessage.SenderUsername) || + string.IsNullOrEmpty(request.FirstMessage.Text)) { - throw new ArgumentException($"Invalid FirstMessage {request.firstMessage}."); + throw new ArgumentException($"Invalid FirstMessage {request.FirstMessage}."); } - string username1 = request.participants.ElementAt(0); - string username2 = request.participants.ElementAt(1); + string username1 = request.Participants.ElementAt(0); + string username2 = request.Participants.ElementAt(1); if (!await _profileService.ProfileExists(username1)) { @@ -72,37 +72,37 @@ public async Task CreateConversation(StartConversatio ////////////// MOVED TO message servicve -- make sure all gucci SendMessageRequest sendMessageRequest = new SendMessageRequest { - id = request.firstMessage.id, - SenderUsername = request.firstMessage.SenderUsername, - text = request.firstMessage.text + MessageId = request.FirstMessage.MessageId, + SenderUsername = request.FirstMessage.SenderUsername, + Text = request.FirstMessage.Text }; - await _messageService.AddMessage(conversationId, true, sendMessageRequest); + await _messageService.AddFirstMessage(conversationId, sendMessageRequest); ////////////////////////////////////////////////////////// UserConversation userConversation1 = new UserConversation { - username = username1, - conversationId = conversationId, - lastModifiedTime = unixTimeNow + Username = username1, + ConversationId = conversationId, + LastModifiedTime = unixTimeNow }; - await _conversationStore.CreateUserConversation(userConversation1); + await _userConversationStore.CreateUserConversation(userConversation1); UserConversation userConversation2 = new UserConversation { - username = username2, - conversationId = conversationId, - lastModifiedTime = unixTimeNow + Username = username2, + ConversationId = conversationId, + LastModifiedTime = unixTimeNow }; - await _conversationStore.CreateUserConversation(userConversation2); + await _userConversationStore.CreateUserConversation(userConversation2); return new StartConversationResponse { - Id = conversationId, + ConversationId = conversationId, CreatedUnixTime = unixTimeNow }; } - public async Task GetConversations( + public async Task GetUserConversations( string username, int limit, OrderBy orderBy, string? continuationToken, long lastSeenConversationTime) { if (string.IsNullOrEmpty(username)) @@ -121,15 +121,20 @@ public async Task GetConversations( $"Invalid lastSeenConversationTime {lastSeenConversationTime}. lastSeenConversationTime must be greater or equal to 0."); } - var result = await _conversationStore.GetUserConversations( + if (!await _profileService.ProfileExists(username)) + { + throw new UserNotFoundException($"User {username} was not found."); + } + + var result = await _userConversationStore.GetUserConversations( username, limit, orderBy, continuationToken, lastSeenConversationTime); List conversations = await UserConversationsToConversations(result.UserConversations); - return new GetConversationsResponse + return new GetUserConversationsServiceResult { - conversations = conversations, - nextContinuationToken = result.NextContinuationToken + Conversations = conversations, + NextContinuationToken = result.NextContinuationToken }; } @@ -139,10 +144,10 @@ private async Task> UserConversationsToConversations(List> UserConversationsToConversations(List GetMessage(string conversationId, string messageId) IQueryable query = Container .GetItemLinqQueryable(false, continuationToken, options) - .Where(e => e.partitionKey == conversationId && e.unixTime > lastSeenMessageTime); + .Where(e => e.partitionKey == conversationId && e.UnixTime > lastSeenMessageTime); if (order == OrderBy.ASC) { - query = query.OrderBy(e => e.unixTime); + query = query.OrderBy(e => e.UnixTime); } else { - query = query.OrderByDescending(e => e.unixTime); + query = query.OrderByDescending(e => e.UnixTime); } using (FeedIterator iterator = query.ToFeedIterator()) @@ -158,10 +158,10 @@ private static MessageEntity ToEntity(string conversationId, Message message) { return new MessageEntity( partitionKey: conversationId, - id: message.id, - message.unixTime, - message.senderUsername, - message.text + id: message.MessageId, + message.UnixTime, + message.SenderUsername, + message.Text ); } @@ -169,10 +169,10 @@ private static Message ToMessage(MessageEntity entity) { return new Message { - id = entity.id, - unixTime = entity.unixTime, - senderUsername = entity.senderUsername, - text = entity.text + MessageId = entity.id, + UnixTime = entity.UnixTime, + SenderUsername = entity.SenderUsername, + Text = entity.Text }; } } \ No newline at end of file diff --git a/ChatService.Web/Storage/CosmosProfileStore.cs b/ChatService.Web/Storage/CosmosProfileStore.cs index 631658a..48cc509 100644 --- a/ChatService.Web/Storage/CosmosProfileStore.cs +++ b/ChatService.Web/Storage/CosmosProfileStore.cs @@ -20,10 +20,10 @@ public CosmosProfileStore(CosmosClient cosmosClient) public async Task AddProfile(Profile profile) { if (profile == null || - string.IsNullOrWhiteSpace(profile.username) || - string.IsNullOrWhiteSpace(profile.firstName) || - string.IsNullOrWhiteSpace(profile.lastName) || - string.IsNullOrWhiteSpace(profile.profilePictureId) + string.IsNullOrWhiteSpace(profile.Username) || + string.IsNullOrWhiteSpace(profile.FirstName) || + string.IsNullOrWhiteSpace(profile.LastName) || + string.IsNullOrWhiteSpace(profile.ProfilePictureId) ) { throw new ArgumentException($"Invalid profile {profile}", nameof(profile)); @@ -31,13 +31,13 @@ public async Task AddProfile(Profile profile) try { - await Container.CreateItemAsync(ToEntity(profile), new PartitionKey(profile.username)); + await Container.CreateItemAsync(ToEntity(profile), new PartitionKey(profile.Username)); } catch (CosmosException e) { if (e.StatusCode == HttpStatusCode.Conflict) { - throw new UsernameTakenException($"A profile with username {profile.username} already exists."); + throw new UsernameTakenException($"A profile with username {profile.Username} already exists."); } throw; } @@ -95,21 +95,21 @@ public async Task ProfileExists(string username) private static ProfileEntity ToEntity(Profile profile) { return new ProfileEntity( - partitionKey: profile.username, - id: profile.username, - profile.firstName, - profile.lastName, - profile.profilePictureId + partitionKey: profile.Username, + id: profile.Username, + profile.FirstName, + profile.LastName, + profile.ProfilePictureId ); } private static Profile ToProfile(ProfileEntity entity) { return new Profile( - username: entity.id, - entity.firstName, - entity.lastName, - entity.profilePictureId + Username: entity.id, + entity.FirstName, + entity.LastName, + entity.ProfilePictureId ); } } \ No newline at end of file diff --git a/ChatService.Web/Storage/CosmosConversationStore.cs b/ChatService.Web/Storage/CosmosUserConversationStore.cs similarity index 84% rename from ChatService.Web/Storage/CosmosConversationStore.cs rename to ChatService.Web/Storage/CosmosUserConversationStore.cs index e06502f..abedf9b 100644 --- a/ChatService.Web/Storage/CosmosConversationStore.cs +++ b/ChatService.Web/Storage/CosmosUserConversationStore.cs @@ -8,11 +8,11 @@ namespace ChatService.Web.Storage; -public class CosmosConversationStore : IConversationStore +public class CosmosUserConversationStore : IUserConversationStore { private readonly CosmosClient _cosmosClient; - public CosmosConversationStore(CosmosClient cosmosClient) + public CosmosUserConversationStore(CosmosClient cosmosClient) { _cosmosClient = cosmosClient; } @@ -22,9 +22,9 @@ public CosmosConversationStore(CosmosClient cosmosClient) public async Task CreateUserConversation(UserConversation userConversation) { if (userConversation == null || - string.IsNullOrWhiteSpace(userConversation.username) || - string.IsNullOrWhiteSpace(userConversation.conversationId) || - userConversation.lastModifiedTime < 0 + string.IsNullOrWhiteSpace(userConversation.Username) || + string.IsNullOrWhiteSpace(userConversation.ConversationId) || + userConversation.LastModifiedTime < 0 ) { throw new ArgumentException($"Invalid user conversation {userConversation}", nameof(userConversation)); @@ -32,13 +32,13 @@ public async Task CreateUserConversation(UserConversation userConversation) try { - await Container.CreateItemAsync(ToEntity(userConversation), new PartitionKey(userConversation.username)); + await Container.CreateItemAsync(ToEntity(userConversation), new PartitionKey(userConversation.Username)); } catch (CosmosException e) { if (e.StatusCode == HttpStatusCode.Conflict) { - throw new UserConversationExistsException($"A user conversation with conversation ID {userConversation.conversationId} already exists."); + throw new UserConversationExistsException($"A user conversation with conversation ID {userConversation.ConversationId} already exists."); } throw; } @@ -103,15 +103,15 @@ public async Task GetUserConversation(string username, string IQueryable query = Container .GetItemLinqQueryable(false, continuationToken, options) - .Where(e => e.partitionKey == username && e.lastModifiedTime > lastSeenConversationTime); + .Where(e => e.partitionKey == username && e.LastModifiedTime > lastSeenConversationTime); if (order == OrderBy.ASC) { - query = query.OrderBy(e => e.lastModifiedTime); + query = query.OrderBy(e => e.LastModifiedTime); } else { - query = query.OrderByDescending(e => e.lastModifiedTime); + query = query.OrderByDescending(e => e.LastModifiedTime); } using (FeedIterator iterator = query.ToFeedIterator()) @@ -148,18 +148,18 @@ await Container.DeleteItemAsync( private static UserConversationEntity ToEntity(UserConversation userConversation) { return new UserConversationEntity( - partitionKey: userConversation.username, - id: userConversation.conversationId, - userConversation.lastModifiedTime + 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 + Username = entity.partitionKey, + ConversationId = entity.id, + LastModifiedTime = entity.LastModifiedTime }; } } \ No newline at end of file diff --git a/ChatService.Web/Storage/Entities/MessageEntity.cs b/ChatService.Web/Storage/Entities/MessageEntity.cs index 02c3adf..f961f81 100644 --- a/ChatService.Web/Storage/Entities/MessageEntity.cs +++ b/ChatService.Web/Storage/Entities/MessageEntity.cs @@ -1,11 +1,8 @@ -using Microsoft.Azure.Cosmos.Serialization.HybridRow; - namespace ChatService.Web.Storage.Entities; public record MessageEntity( string partitionKey, string id, - long unixTime, - string senderUsername, - string text - ); \ No newline at end of file + 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 index afb63cb..f7b41f3 100644 --- a/ChatService.Web/Storage/Entities/ProfileEntity.cs +++ b/ChatService.Web/Storage/Entities/ProfileEntity.cs @@ -3,6 +3,6 @@ namespace ChatService.Web.Storage.Entities; public record ProfileEntity( string partitionKey, string id, - string firstName, - string lastName, - string profilePictureId); + string FirstName, + string LastName, + string ProfilePictureId); diff --git a/ChatService.Web/Storage/Entities/UserConversationEntity.cs b/ChatService.Web/Storage/Entities/UserConversationEntity.cs index 4fc3560..bd39b7d 100644 --- a/ChatService.Web/Storage/Entities/UserConversationEntity.cs +++ b/ChatService.Web/Storage/Entities/UserConversationEntity.cs @@ -1,9 +1,6 @@ -using Microsoft.Azure.Cosmos.Serialization.HybridRow; - namespace ChatService.Web.Storage.Entities; public record UserConversationEntity( string partitionKey, string id, - long lastModifiedTime - ); \ No newline at end of file + long LastModifiedTime); \ No newline at end of file diff --git a/ChatService.Web/Storage/IConversationStore.cs b/ChatService.Web/Storage/IUserConversationStore.cs similarity index 93% rename from ChatService.Web/Storage/IConversationStore.cs rename to ChatService.Web/Storage/IUserConversationStore.cs index caf969d..d1840a1 100644 --- a/ChatService.Web/Storage/IConversationStore.cs +++ b/ChatService.Web/Storage/IUserConversationStore.cs @@ -4,7 +4,7 @@ namespace ChatService.Web.Storage; -public interface IConversationStore +public interface IUserConversationStore { Task CreateUserConversation(UserConversation userConversation); Task GetUserConversation(string username, string conversationId); diff --git a/ChatService.sln.DotSettings.user b/ChatService.sln.DotSettings.user index de57a75..d7d0414 100644 --- a/ChatService.sln.DotSettings.user +++ b/ChatService.sln.DotSettings.user @@ -5,9 +5,30 @@ On C:\Users\nadim\AppData\Local\JetBrains\Rider2022.3\resharper-host\temp\Rider\vAny\CoverageData\_ChatService.34854384\Snapshot\snapshot.utdcvr <SessionState ContinuousTestingMode="0" Name="GetUserConversations_Ordered_Successful" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <TestAncestor> + <TestId>xUnit::A7808CB8-B561-4939-9B4E-8AC0FD786DC3::net6.0::ChatService.Web.IntegrationTests.CosmosConversationStoreTests</TestId> + <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ImageControllerTests</TestId> + </TestAncestor> +</SessionState> + <SessionState ContinuousTestingMode="0" IsActive="True" Name="ImageControllerTests" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> </SessionState> - <SessionState ContinuousTestingMode="0" IsActive="True" Name="Session" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <SessionState ContinuousTestingMode="0" Name="ProfileControllerTests" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <TestAncestor> + <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ProfileControllerTests</TestId> + <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Services.ProfileServiceTests</TestId> + <TestId>xUnit::A7808CB8-B561-4939-9B4E-8AC0FD786DC3::net6.0::ChatService.Web.IntegrationTests.CosmosMessageStoreTests.AddMessage_Successful</TestId> + <TestId>xUnit::A7808CB8-B561-4939-9B4E-8AC0FD786DC3::net6.0::ChatService.Web.IntegrationTests.CosmosMessageStoreTests.AddMessage_InvalidArguments</TestId> + <TestId>xUnit::A7808CB8-B561-4939-9B4E-8AC0FD786DC3::net6.0::ChatService.Web.IntegrationTests.CosmosMessageStoreTests</TestId> + </TestAncestor> +</SessionState> + <SessionState ContinuousTestingMode="0" Name="CosmosMessageStoreTests" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <TestAncestor> + <TestId>xUnit::A7808CB8-B561-4939-9B4E-8AC0FD786DC3::net6.0::ChatService.Web.IntegrationTests.CosmosMessageStoreTests</TestId> + <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Services.ProfileServiceTests</TestId> + </TestAncestor> +</SessionState> + <SessionState ContinuousTestingMode="0" Name="Session" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> </SessionState> From b4df943e1251a73a48c742acbbcd1e97cd4ea876 Mon Sep 17 00:00:00 2001 From: Ali Date: Thu, 30 Mar 2023 17:57:08 +0300 Subject: [PATCH 32/45] Refactor & start ImageService --- .../BlobImageStoreTests.cs | 30 +++++------ .../CosmosConversationStoreTests.cs | 42 ++++++++-------- .../CosmosMessageStoreTests.cs | 50 +++++++++---------- .../CosmosProfileStoreTests.cs | 34 ++++++------- .../Dtos/UploadImageServiceResult.cs | 4 ++ ChatService.Web/Services/IImageService.cs | 10 ++++ ChatService.Web/Services/ImageService.cs | 25 ++++++++++ 7 files changed, 117 insertions(+), 78 deletions(-) create mode 100644 ChatService.Web/Dtos/UploadImageServiceResult.cs create mode 100644 ChatService.Web/Services/IImageService.cs create mode 100644 ChatService.Web/Services/ImageService.cs diff --git a/ChatService.Web.IntegrationTests/BlobImageStoreTests.cs b/ChatService.Web.IntegrationTests/BlobImageStoreTests.cs index 77776fe..babf34d 100644 --- a/ChatService.Web.IntegrationTests/BlobImageStoreTests.cs +++ b/ChatService.Web.IntegrationTests/BlobImageStoreTests.cs @@ -9,7 +9,7 @@ namespace ChatService.Web.IntegrationTests; public class BlobImageStoreTests : IClassFixture>, IAsyncLifetime { - private readonly IImageStore _store; + private readonly IImageStore _imageStore; private readonly Image _image = new Image("image/jpg", new MemoryStream(Encoding.UTF8.GetBytes("This is a mock image file content"))); private string _imageId; @@ -21,19 +21,19 @@ public Task InitializeAsync() public async Task DisposeAsync() { - await _store.DeleteImage(_imageId); + await _imageStore.DeleteImage(_imageId); } public BlobImageStoreTests(WebApplicationFactory factory) { - _store = factory.Services.GetRequiredService(); + _imageStore = factory.Services.GetRequiredService(); } [Fact] public async Task UploadImage_Success() { - string imageId = await _store.UploadImage(_image); - var downloadedImage = await _store.DownloadImage(imageId); + string imageId = await _imageStore.UploadImage(_image); + var downloadedImage = await _imageStore.DownloadImage(imageId); Assert.Equal(_image.ContentType, downloadedImage.ContentType); Assert.True(_image.Content.ToArray().SequenceEqual(downloadedImage.Content.ToArray())); @@ -47,14 +47,14 @@ public async Task UploadImage_Failure() var notImage = new Image("text/plain", new MemoryStream(Encoding.UTF8.GetBytes("This is a mock file simulating an invalid image type"))); - await Assert.ThrowsAsync(async () => await _store.UploadImage(notImage)); + await Assert.ThrowsAsync(async () => await _imageStore.UploadImage(notImage)); } [Fact] public async Task DownloadImage_Success() { - var imageId = await _store.UploadImage(_image); - var downloadedImage = await _store.DownloadImage(imageId); + var imageId = await _imageStore.UploadImage(_image); + var downloadedImage = await _imageStore.DownloadImage(imageId); Assert.Equal(_image.ContentType, downloadedImage.ContentType); Assert.True(_image.Content.ToArray().SequenceEqual(downloadedImage.Content.ToArray())); @@ -63,7 +63,7 @@ public async Task DownloadImage_Success() [Fact] public async Task DownloadImage_NotFound() { - var downloadedImage = await _store.DownloadImage("dummy_id"); + var downloadedImage = await _imageStore.DownloadImage("dummy_id"); Assert.Null(downloadedImage); } @@ -71,22 +71,22 @@ public async Task DownloadImage_NotFound() [Fact] public async Task DeleteImage_Success() { - var imageId = await _store.UploadImage(_image); - Assert.True(await _store.DeleteImage(imageId)); + var imageId = await _imageStore.UploadImage(_image); + Assert.True(await _imageStore.DeleteImage(imageId)); } [Fact] public async Task DeleteImage_Failure() { - Assert.False(await _store.DeleteImage("dummy_id")); + Assert.False(await _imageStore.DeleteImage("dummy_id")); } [Fact] public async Task ImageExists_Exists() { - string imageId = await _store.UploadImage(_image); + string imageId = await _imageStore.UploadImage(_image); - Assert.True(await _store.ImageExists(imageId)); + Assert.True(await _imageStore.ImageExists(imageId)); _imageId = imageId; } @@ -94,6 +94,6 @@ public async Task ImageExists_Exists() [Fact] public async Task ImageExists_DoesntExist() { - Assert.False(await _store.ImageExists("dummy_id")); + Assert.False(await _imageStore.ImageExists("dummy_id")); } } \ No newline at end of file diff --git a/ChatService.Web.IntegrationTests/CosmosConversationStoreTests.cs b/ChatService.Web.IntegrationTests/CosmosConversationStoreTests.cs index 12a44f5..88b8330 100644 --- a/ChatService.Web.IntegrationTests/CosmosConversationStoreTests.cs +++ b/ChatService.Web.IntegrationTests/CosmosConversationStoreTests.cs @@ -9,7 +9,7 @@ namespace ChatService.Web.IntegrationTests; public class CosmosConversationStoreTests : IClassFixture>, IAsyncLifetime { - private readonly IUserConversationStore _store; + private readonly IUserConversationStore _userConversationStore; private static readonly UserConversation _userConversation = new UserConversation { @@ -41,15 +41,15 @@ public class CosmosConversationStoreTests : IClassFixture factory) { - _store = factory.Services.GetRequiredService(); + _userConversationStore = factory.Services.GetRequiredService(); } [Fact] public async Task CreateUserConversation_Successful() { - await _store.CreateUserConversation(_userConversation); + await _userConversationStore.CreateUserConversation(_userConversation); - Assert.Equal(_userConversation, await _store.GetUserConversation(_userConversation.Username, _userConversation.ConversationId)); + Assert.Equal(_userConversation, await _userConversationStore.GetUserConversation(_userConversation.Username, _userConversation.ConversationId)); } [Theory] @@ -70,16 +70,16 @@ public async Task CreateUserConversation_InvalidArguments(string username, strin }; await Assert.ThrowsAsync( - () => _store.CreateUserConversation(userConversation)); + () => _userConversationStore.CreateUserConversation(userConversation)); } [Fact] public async Task CreateUserConversation_ConversationAlreadyExists() { - await _store.CreateUserConversation(_userConversation); + await _userConversationStore.CreateUserConversation(_userConversation); await Assert.ThrowsAsync( - () => _store.CreateUserConversation(_userConversation)); + () => _userConversationStore.CreateUserConversation(_userConversation)); } [Theory] @@ -92,14 +92,14 @@ await Assert.ThrowsAsync( public async Task GetUserConversation_InvalidArguments(string username, string conversationId) { await Assert.ThrowsAsync( - () => _store.GetUserConversation(username, conversationId)); + () => _userConversationStore.GetUserConversation(username, conversationId)); } [Fact] public async Task GetUserConversation_ConversationNotFound() { await Assert.ThrowsAsync( - () => _store.GetUserConversation(_userConversation.Username, _userConversation.ConversationId)); + () => _userConversationStore.GetUserConversation(_userConversation.Username, _userConversation.ConversationId)); } [Fact] @@ -107,13 +107,13 @@ public async Task GetUserConversations_Limit() { await AddMultipleUserConversations(_userConversation1, _userConversation2, _userConversation3); - var response = await _store.GetUserConversations(_userConversation.Username, 1, OrderBy.ASC, null, 1); + var response = await _userConversationStore.GetUserConversations(_userConversation.Username, 1, OrderBy.ASC, null, 1); Assert.Equal(1, response.UserConversations.Count); - response = await _store.GetUserConversations(_userConversation.Username, 2, OrderBy.ASC, null, 1); + response = await _userConversationStore.GetUserConversations(_userConversation.Username, 2, OrderBy.ASC, null, 1); Assert.Equal(2, response.UserConversations.Count); - response = await _store.GetUserConversations(_userConversation.Username, 3, OrderBy.ASC, null, 1); + response = await _userConversationStore.GetUserConversations(_userConversation.Username, 3, OrderBy.ASC, null, 1); Assert.Equal(3, response.UserConversations.Count); await DeleteMultipleUserConversations(_userConversation1, _userConversation2, _userConversation3); @@ -130,7 +130,7 @@ public async Task GetUserConversations_OrderBy(OrderBy orderBy) await AddMultipleUserConversations( _userConversation, _userConversation1, _userConversation2, _userConversation3); - var response = await _store.GetUserConversations(_userConversation.Username, 10, orderBy, null, 0); + var response = await _userConversationStore.GetUserConversations(_userConversation.Username, 10, orderBy, null, 0); if (orderBy == OrderBy.ASC) { @@ -150,7 +150,7 @@ public async Task GetUserConversations_ContinuationTokenValidity() { await AddMultipleUserConversations(_userConversation1, _userConversation2, _userConversation3); - var response = await _store.GetUserConversations( + var response = await _userConversationStore.GetUserConversations( _userConversation.Username, 1, OrderBy.ASC, null, 1); Assert.Equal(_userConversation1, response.UserConversations.ElementAt(0)); @@ -158,13 +158,13 @@ public async Task GetUserConversations_ContinuationTokenValidity() var nextContinuation = response.NextContinuationToken; Assert.NotNull(nextContinuation); - response = await _store.GetUserConversations(_userConversation.Username, 1, OrderBy.ASC, nextContinuation, 1); + response = await _userConversationStore.GetUserConversations(_userConversation.Username, 1, OrderBy.ASC, nextContinuation, 1); Assert.Equal(_userConversation2, response.UserConversations.ElementAt(0)); nextContinuation = response.NextContinuationToken; Assert.NotNull(nextContinuation); - response = await _store.GetUserConversations(_userConversation.Username, 1, OrderBy.ASC, nextContinuation, 1); + response = await _userConversationStore.GetUserConversations(_userConversation.Username, 1, OrderBy.ASC, nextContinuation, 1); Assert.Equal(_userConversation3, response.UserConversations.ElementAt(0)); nextContinuation = response.NextContinuationToken; @@ -189,7 +189,7 @@ public async Task GetUserConversations_LastSeenConversationTime(long lastSeenCon if(_userConversation3.LastModifiedTime > lastSeenConversationTime) { userConversationsExpected.Add(_userConversation3);} if(_userConversation.LastModifiedTime > lastSeenConversationTime) { userConversationsExpected.Add(_userConversation);} - var response = await _store.GetUserConversations(_userConversation.Username, 10, OrderBy.ASC, null, lastSeenConversationTime); + var response = await _userConversationStore.GetUserConversations(_userConversation.Username, 10, OrderBy.ASC, null, lastSeenConversationTime); Assert.Equal(userConversationsExpected, response.UserConversations); @@ -206,14 +206,14 @@ public async Task GetUserConversations_LastSeenConversationTime(long lastSeenCon public async Task GetUserConversations_InvalidArguments(string username, int limit, long lastSeenConversationTime) { await Assert.ThrowsAsync( - () => _store.GetUserConversations(username, limit, OrderBy.ASC, null, lastSeenConversationTime)); + () => _userConversationStore.GetUserConversations(username, limit, OrderBy.ASC, null, lastSeenConversationTime)); } private async Task AddMultipleUserConversations(params UserConversation[] userConversations) { foreach (UserConversation userConversation in userConversations) { - await _store.CreateUserConversation(userConversation); + await _userConversationStore.CreateUserConversation(userConversation); } } @@ -233,7 +233,7 @@ private async Task DeleteMultipleUserConversations(params UserConversation[] use { foreach (UserConversation userConversation in userConversations) { - await _store.DeleteUserConversation(userConversation.Username, userConversation.ConversationId); + await _userConversationStore.DeleteUserConversation(userConversation.Username, userConversation.ConversationId); } } @@ -244,6 +244,6 @@ public Task InitializeAsync() public async Task DisposeAsync() { - await _store.DeleteUserConversation(_userConversation.Username, _userConversation.ConversationId); + await _userConversationStore.DeleteUserConversation(_userConversation.Username, _userConversation.ConversationId); } } \ No newline at end of file diff --git a/ChatService.Web.IntegrationTests/CosmosMessageStoreTests.cs b/ChatService.Web.IntegrationTests/CosmosMessageStoreTests.cs index de6e4cc..016e324 100644 --- a/ChatService.Web.IntegrationTests/CosmosMessageStoreTests.cs +++ b/ChatService.Web.IntegrationTests/CosmosMessageStoreTests.cs @@ -9,7 +9,7 @@ namespace ChatService.Web.IntegrationTests; public class CosmosMessageStoreTests : IClassFixture>, IAsyncLifetime { - private readonly IMessageStore _store; + private readonly IMessageStore _messageStore; private readonly string _conversationId = Guid.NewGuid().ToString(); @@ -39,15 +39,15 @@ public class CosmosMessageStoreTests : IClassFixture factory) { - _store = factory.Services.GetRequiredService(); + _messageStore = factory.Services.GetRequiredService(); } [Fact] public async Task AddMessage_Successful() { - await _store.AddMessage(_conversationId, _message1); + await _messageStore.AddMessage(_conversationId, _message1); - Assert.Equal(_message1, await _store.GetMessage(_conversationId, _message1.MessageId)); + Assert.Equal(_message1, await _messageStore.GetMessage(_conversationId, _message1.MessageId)); } [Theory] @@ -71,15 +71,15 @@ public async Task AddMessage_InvalidArguments(string id, string senderUsername, Text = text }; - await Assert.ThrowsAsync(() => _store.AddMessage(_conversationId, message)); + await Assert.ThrowsAsync(() => _messageStore.AddMessage(_conversationId, message)); } [Fact] public async Task AddMessage_MessageAlreadyExists() { - await _store.AddMessage(_conversationId, _message1); + await _messageStore.AddMessage(_conversationId, _message1); - await Assert.ThrowsAsync(() => _store.AddMessage(_conversationId, _message1)); + await Assert.ThrowsAsync(() => _messageStore.AddMessage(_conversationId, _message1)); } [Theory] @@ -91,13 +91,13 @@ public async Task AddMessage_MessageAlreadyExists() [InlineData("conversationId", " ")] public async Task GetMessage_InvalidArguments(string conversationId, string messageId) { - await Assert.ThrowsAsync(() => _store.GetMessage(conversationId, messageId)); + await Assert.ThrowsAsync(() => _messageStore.GetMessage(conversationId, messageId)); } [Fact] public async Task GetMessage_MessageNotFound() { - await Assert.ThrowsAsync(() => _store.GetMessage(_conversationId, _message1.MessageId)); + await Assert.ThrowsAsync(() => _messageStore.GetMessage(_conversationId, _message1.MessageId)); } [Fact] @@ -105,14 +105,14 @@ public async Task GetMessages_Limit() { await AddMultipleMessages(_conversationId, _message1, _message2, _message3); - var response = await _store.GetMessages( + var response = await _messageStore.GetMessages( _conversationId, 1, OrderBy.ASC, null, 1); Assert.Equal(1, response.Messages.Count); - response = await _store.GetMessages(_conversationId, 2, OrderBy.ASC, null, 1); + response = await _messageStore.GetMessages(_conversationId, 2, OrderBy.ASC, null, 1); Assert.Equal(2, response.Messages.Count); - response = await _store.GetMessages(_conversationId, 3, OrderBy.ASC, null, 1); + response = await _messageStore.GetMessages(_conversationId, 3, OrderBy.ASC, null, 1); Assert.Equal(3, response.Messages.Count); await DeleteMultipleMessages(_conversationId, _message1, _message2, _message3); @@ -129,7 +129,7 @@ public async Task GetMessages_OrderBy(OrderBy orderBy) messagesExpected.Add(_message1); messagesExpected.Add(_message2); - var response = await _store.GetMessages( + var response = await _messageStore.GetMessages( _conversationId, 10, orderBy, null, 1); if (orderBy == OrderBy.ASC) @@ -142,7 +142,7 @@ public async Task GetMessages_OrderBy(OrderBy orderBy) Assert.Equal(messagesExpected, response.Messages); } - await _store.DeleteMessage(_conversationId, _message2.MessageId); + await _messageStore.DeleteMessage(_conversationId, _message2.MessageId); } [Fact] @@ -150,17 +150,17 @@ public async Task GetMessages_ContinuationTokenValidity() { await AddMultipleMessages(_conversationId, _message1, _message2, _message3); - var response = await _store.GetMessages( + var response = await _messageStore.GetMessages( _conversationId, 1, OrderBy.ASC, null, 1); Assert.Equal(_message1, response.Messages.ElementAt(0)); Assert.NotNull(response.NextContinuationToken); - response = await _store.GetMessages( + response = await _messageStore.GetMessages( _conversationId, 1, OrderBy.ASC, response.NextContinuationToken, 1); Assert.Equal(_message2, response.Messages.ElementAt(0)); Assert.NotNull(response.NextContinuationToken); - response = await _store.GetMessages( + response = await _messageStore.GetMessages( _conversationId, 1, OrderBy.ASC, response.NextContinuationToken, 1); Assert.Equal(_message3, response.Messages.ElementAt(0)); Assert.Null(response.NextContinuationToken); @@ -182,7 +182,7 @@ public async Task GetMessages_LastSeenMessageTime(long lastSeenMessageTime) if(_message2.UnixTime > lastSeenMessageTime) messagesExpected.Add(_message2); if(_message3.UnixTime > lastSeenMessageTime) messagesExpected.Add(_message3); - var response = await _store.GetMessages( + var response = await _messageStore.GetMessages( _conversationId, 10, OrderBy.ASC, null, lastSeenMessageTime); Assert.Equal(messagesExpected, response.Messages); @@ -200,28 +200,28 @@ public async Task GetMessages_LastSeenMessageTime(long lastSeenMessageTime) public async Task GetMessages_InvalidArguments(string conversationId, int limit, long lastSeenMessageTime) { Assert.ThrowsAsync(() => - _store.GetMessages(conversationId, limit, OrderBy.ASC, null, lastSeenMessageTime)); + _messageStore.GetMessages(conversationId, limit, OrderBy.ASC, null, lastSeenMessageTime)); } [Fact] public async Task ConversationPartitionExists_Exists() { - await _store.AddMessage(_conversationId, _message1); + await _messageStore.AddMessage(_conversationId, _message1); - Assert.True(await _store.ConversationPartitionExists(_conversationId)); + Assert.True(await _messageStore.ConversationPartitionExists(_conversationId)); } [Fact] public async Task ConversationPartitionExists_DoesNotExists() { - Assert.False(await _store.ConversationPartitionExists(_conversationId)); + Assert.False(await _messageStore.ConversationPartitionExists(_conversationId)); } private async Task AddMultipleMessages(string conversationId, params Message[] messages) { foreach (Message message in messages) { - await _store.AddMessage(conversationId, message); + await _messageStore.AddMessage(conversationId, message); } } @@ -229,7 +229,7 @@ private async Task DeleteMultipleMessages(string conversationId, params Message[ { foreach (Message message in messages) { - await _store.DeleteMessage(conversationId, message.MessageId); + await _messageStore.DeleteMessage(conversationId, message.MessageId); } } @@ -240,6 +240,6 @@ public Task InitializeAsync() public async Task DisposeAsync() { - await _store.DeleteMessage(_conversationId, _message1.MessageId); + await _messageStore.DeleteMessage(_conversationId, _message1.MessageId); } } \ No newline at end of file diff --git a/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs b/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs index b87ff6f..aadea9f 100644 --- a/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs +++ b/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs @@ -8,7 +8,7 @@ namespace ChatService.Web.IntegrationTests; public class CosmosProfileStoreTest : IClassFixture>, IAsyncLifetime { - private readonly IProfileStore _store; + private readonly IProfileStore _profileStore; private readonly Profile _profile = new( Username: Guid.NewGuid().ToString(), @@ -24,19 +24,19 @@ public Task InitializeAsync() public async Task DisposeAsync() { - await _store.DeleteProfile(_profile.Username); + await _profileStore.DeleteProfile(_profile.Username); } public CosmosProfileStoreTest(WebApplicationFactory factory) { - _store = factory.Services.GetRequiredService(); + _profileStore = factory.Services.GetRequiredService(); } [Fact] public async Task AddNewProfile_Success() { - await _store.AddProfile(_profile); - Assert.Equal(_profile, await _store.GetProfile(_profile.Username)); + await _profileStore.AddProfile(_profile); + Assert.Equal(_profile, await _profileStore.GetProfile(_profile.Username)); } [Theory] @@ -55,47 +55,47 @@ public async Task AddNewProfile_Success() public async Task AddNewProfile_InvalidArgs(string username, string firstName, string lastName, string profilePictureId) { Profile profile = new(username, firstName, lastName, profilePictureId); - await Assert.ThrowsAsync( async () => await _store.AddProfile(profile)); + await Assert.ThrowsAsync( async () => await _profileStore.AddProfile(profile)); } [Fact] public async Task AddNewProfile_NullProfile() { - await Assert.ThrowsAsync( async () => await _store.AddProfile(null)); + await Assert.ThrowsAsync( async () => await _profileStore.AddProfile(null)); } [Fact] public async Task AddNewProfile_UsernameTaken() { - await _store.AddProfile(_profile); - await Assert.ThrowsAsync( async () => await _store.AddProfile(_profile)); + await _profileStore.AddProfile(_profile); + await Assert.ThrowsAsync( async () => await _profileStore.AddProfile(_profile)); } [Fact] public async Task GetNonExistingProfile() { - Assert.Null(await _store.GetProfile(_profile.Username)); + Assert.Null(await _profileStore.GetProfile(_profile.Username)); } [Fact] public async Task DeleteProfile() { - await _store.AddProfile(_profile); - Assert.Equal(_profile, await _store.GetProfile(_profile.Username)); - await _store.DeleteProfile(_profile.Username); - Assert.Null(await _store.GetProfile(_profile.Username)); + 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 _store.AddProfile(_profile); - Assert.True(await _store.ProfileExists(_profile.Username)); + await _profileStore.AddProfile(_profile); + Assert.True(await _profileStore.ProfileExists(_profile.Username)); } [Fact] public async Task ProfileExists_DoesNotExist() { - Assert.False(await _store.ProfileExists(_profile.Username)); + Assert.False(await _profileStore.ProfileExists(_profile.Username)); } } \ No newline at end of file diff --git a/ChatService.Web/Dtos/UploadImageServiceResult.cs b/ChatService.Web/Dtos/UploadImageServiceResult.cs new file mode 100644 index 0000000..686c9e2 --- /dev/null +++ b/ChatService.Web/Dtos/UploadImageServiceResult.cs @@ -0,0 +1,4 @@ +namespace ChatService.Web.Dtos; + +public record UploadImageServiceResult( + string ImageId); \ No newline at end of file diff --git a/ChatService.Web/Services/IImageService.cs b/ChatService.Web/Services/IImageService.cs new file mode 100644 index 0000000..c4210e5 --- /dev/null +++ b/ChatService.Web/Services/IImageService.cs @@ -0,0 +1,10 @@ +using ChatService.Web.Dtos; +using Microsoft.AspNetCore.Mvc; + +namespace ChatService.Web.Services; + +public interface IImageService +{ + Task UploadImage(Image image); + Task DownloadImage(string imageId); +} \ 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..41ad565 --- /dev/null +++ b/ChatService.Web/Services/ImageService.cs @@ -0,0 +1,25 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Storage; +using Microsoft.AspNetCore.Mvc; + +namespace ChatService.Web.Services; + +public class ImageService : IImageService +{ + private readonly IImageStore _imageStore; + + public ImageService(IImageStore imageStore) + { + _imageStore = imageStore; + } + + public Task UploadImage(Image image) + { + throw new NotImplementedException(); + } + + public Task DownloadImage(string imageId) + { + throw new NotImplementedException(); + } +} \ No newline at end of file From a11b1ba97a81336ebf8f9e22ff20062218650588 Mon Sep 17 00:00:00 2001 From: nadimakk Date: Thu, 30 Mar 2023 20:20:11 +0300 Subject: [PATCH 33/45] begin ConversationServiceTests --- .../Services/ConversationServiceTests.cs | 59 +++++++++++++++++++ ChatService.sln.DotSettings.user | 4 +- 2 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 ChatService.Web.Tests/Services/ConversationServiceTests.cs diff --git a/ChatService.Web.Tests/Services/ConversationServiceTests.cs b/ChatService.Web.Tests/Services/ConversationServiceTests.cs new file mode 100644 index 0000000..18b31c7 --- /dev/null +++ b/ChatService.Web.Tests/Services/ConversationServiceTests.cs @@ -0,0 +1,59 @@ +using ChatService.Web.Dtos; +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 ConversationServiceTests : IClassFixture> +{ + private readonly Mock _messageServiceMock = new(); + private readonly Mock _conversationStoreMock = new(); + private readonly Mock _profileServiceMock = new(); + + private readonly IConversationService _conversationService; + + private readonly List participants = new List + { + Guid.NewGuid().ToString(), + Guid.NewGuid().ToString() + }; + + private readonly SendMessageRequest = new SendMessageRequest + { + + }; + + private readonly StartConversationRequest _startConversationRequest = new StartConversationRequest + { + participants = participants, + + }; + + public ConversationServiceTests(WebApplicationFactory factory) + { + _conversationService = factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddSingleton(_messageServiceMock.Object); + services.AddSingleton(_conversationStoreMock.Object); + services.AddSingleton(_profileServiceMock.Object); + }); + }).Services.GetRequiredService(); + } + + [Fact] + public async Task CreateConversation_Success() + { + _profileServiceMock.Setup(m => m.ProfileExists()) + + _profileStoreMock.Setup(m => m.GetProfile(_profile.username)) + .ReturnsAsync(_profile); + + + } +} \ No newline at end of file diff --git a/ChatService.sln.DotSettings.user b/ChatService.sln.DotSettings.user index de57a75..11a1edb 100644 --- a/ChatService.sln.DotSettings.user +++ b/ChatService.sln.DotSettings.user @@ -4,10 +4,10 @@ On On C:\Users\nadim\AppData\Local\JetBrains\Rider2022.3\resharper-host\temp\Rider\vAny\CoverageData\_ChatService.34854384\Snapshot\snapshot.utdcvr - <SessionState ContinuousTestingMode="0" Name="GetUserConversations_Ordered_Successful" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <SessionState ContinuousTestingMode="0" IsActive="True" Name="GetUserConversations_Ordered_Successful" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> </SessionState> - <SessionState ContinuousTestingMode="0" IsActive="True" Name="Session" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <SessionState ContinuousTestingMode="0" Name="Session" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> </SessionState> From 1b092e9feeab0e87c9cff94bcb53c291890cfd7e Mon Sep 17 00:00:00 2001 From: Ali Date: Thu, 30 Mar 2023 22:28:10 +0300 Subject: [PATCH 34/45] Refactor & Implement ImageService --- ...ollerTests.cs => ImagesControllerTests.cs} | 48 ++++++++++++----- ...lerTests.cs => ProfilesControllerTests.cs} | 4 +- .../Controllers/ConversationsController.cs | 1 + .../Controllers/ImagesController.cs | 52 +++++++++++-------- .../Exceptions/InvalidImageTypeException.cs | 8 +++ ChatService.Web/Program.cs | 1 + ChatService.Web/Services/ImageService.cs | 42 +++++++++++++-- ChatService.sln.DotSettings.user | 14 ++--- 8 files changed, 119 insertions(+), 51 deletions(-) rename ChatService.Web.Tests/Controllers/{ImageControllerTests.cs => ImagesControllerTests.cs} (65%) rename ChatService.Web.Tests/Controllers/{ProfileControllerTests.cs => ProfilesControllerTests.cs} (96%) create mode 100644 ChatService.Web/Exceptions/InvalidImageTypeException.cs diff --git a/ChatService.Web.Tests/Controllers/ImageControllerTests.cs b/ChatService.Web.Tests/Controllers/ImagesControllerTests.cs similarity index 65% rename from ChatService.Web.Tests/Controllers/ImageControllerTests.cs rename to ChatService.Web.Tests/Controllers/ImagesControllerTests.cs index 0981031..ae87d19 100644 --- a/ChatService.Web.Tests/Controllers/ImageControllerTests.cs +++ b/ChatService.Web.Tests/Controllers/ImagesControllerTests.cs @@ -2,7 +2,8 @@ using System.Net.Http.Headers; using System.Text; using ChatService.Web.Dtos; -using ChatService.Web.Storage; +using ChatService.Web.Exceptions; +using ChatService.Web.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; @@ -12,17 +13,17 @@ namespace ChatService.Web.Tests.Controllers; -public class ImageControllerTests : IClassFixture> +public class ImagesControllerTests : IClassFixture> { - private readonly Mock _imageStoreMock = new(); + private readonly Mock _imageServiceMock = new(); private readonly HttpClient _httpClient; private readonly MultipartFormDataContent _content = new(); - public ImageControllerTests(WebApplicationFactory factory) + public ImagesControllerTests(WebApplicationFactory factory) { _httpClient = factory.WithWebHostBuilder(builder => { - builder.ConfigureTestServices(services => { services.AddSingleton(_imageStoreMock.Object); }); + builder.ConfigureTestServices(services => { services.AddSingleton(_imageServiceMock.Object); }); }).CreateClient(); } @@ -33,8 +34,8 @@ public async Task UploadImage_Success() var imageId = Guid.NewGuid().ToString(); var uploadImageResponse = new UploadImageResponse(imageId); - _imageStoreMock.Setup(m => m.UploadImage(It.IsAny())) - .ReturnsAsync(imageId); + _imageServiceMock.Setup(m => m.UploadImage(It.IsAny())) + .ReturnsAsync(new UploadImageServiceResult(imageId)); var fileContent = new StreamContent(image.Content); fileContent.Headers.ContentType = new MediaTypeHeaderValue(image.ContentType); @@ -58,24 +59,27 @@ public async Task UploadImage_MissingFile() Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - _imageStoreMock.Verify( m => m.UploadImage(It.IsAny()), Times.Never); + _imageServiceMock.Verify( m => m.UploadImage(It.IsAny()), Times.Never); } [Fact] public async Task UploadImage_InvalidFile() { - var fileContent = new StreamContent(new MemoryStream(Encoding.UTF8.GetBytes("This is a mock text file content"))); + var content = new MemoryStream(Encoding.UTF8.GetBytes("This is a mock text file content")); + var contentType = "text/plain"; + var fileContent = new StreamContent(content); fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain"); _content.Add(fileContent,"File", "file.txt"); + _imageServiceMock.Setup(m => m.UploadImage(It.IsAny())) + .ThrowsAsync(new InvalidImageTypeException($"Invalid image type {contentType}.")); + var response = await _httpClient.PostAsync("api/Images/", _content); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var json = await response.Content.ReadAsStringAsync(); - Assert.Equal("Invalid file, must be an image.", json); - - _imageStoreMock.Verify( m => m.UploadImage(It.IsAny()), Times.Never); + Assert.Equal($"Invalid image type {contentType}.", json); } [Fact] @@ -85,8 +89,8 @@ public async Task DownloadImage_Success() var image = new Image("image/jpeg", new MemoryStream()); var fileContentResult = new FileContentResult(image.Content.ToArray(), image.ContentType); - _imageStoreMock.Setup(m => m.DownloadImage(imageId)) - .ReturnsAsync(image); + _imageServiceMock.Setup(m => m.DownloadImage(imageId)) + .ReturnsAsync(new FileContentResult(image.Content.ToArray(), image.ContentType)); var response = await _httpClient.GetAsync($"api/Images/{imageId}"); @@ -103,9 +107,25 @@ public async Task DownloadImage_Success() public async Task DownloadImage_NotFound() { var imageId = Guid.NewGuid().ToString(); + + _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() + { + var imageId = Guid.NewGuid().ToString(); + + _imageServiceMock.Setup(m => m.DownloadImage(imageId)) + .ThrowsAsync( new ArgumentException("Invalid imageId")); + + var response = await _httpClient.GetAsync($"api/Images/{imageId}"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } } \ No newline at end of file diff --git a/ChatService.Web.Tests/Controllers/ProfileControllerTests.cs b/ChatService.Web.Tests/Controllers/ProfilesControllerTests.cs similarity index 96% rename from ChatService.Web.Tests/Controllers/ProfileControllerTests.cs rename to ChatService.Web.Tests/Controllers/ProfilesControllerTests.cs index f590907..74fbd63 100644 --- a/ChatService.Web.Tests/Controllers/ProfileControllerTests.cs +++ b/ChatService.Web.Tests/Controllers/ProfilesControllerTests.cs @@ -11,13 +11,13 @@ namespace ChatService.Web.Tests.Controllers; -public class ProfileControllerTests : IClassFixture> +public class ProfilesControllerTests : IClassFixture> { private readonly Mock _profileServiceMock = new(); private readonly HttpClient _httpClient; private readonly Profile _profile = new Profile("foobar", "Foo", "Bar", "123"); - public ProfileControllerTests(WebApplicationFactory factory) + public ProfilesControllerTests(WebApplicationFactory factory) { _httpClient = factory.WithWebHostBuilder(builder => { diff --git a/ChatService.Web/Controllers/ConversationsController.cs b/ChatService.Web/Controllers/ConversationsController.cs index 484790e..a875ba9 100644 --- a/ChatService.Web/Controllers/ConversationsController.cs +++ b/ChatService.Web/Controllers/ConversationsController.cs @@ -57,6 +57,7 @@ public async Task> GetUserConversatio [HttpPost] public async Task> StartConversation(StartConversationRequest request) { + //TODO: add a start conversation service result StartConversationResponse response; try diff --git a/ChatService.Web/Controllers/ImagesController.cs b/ChatService.Web/Controllers/ImagesController.cs index ad14795..68c6952 100644 --- a/ChatService.Web/Controllers/ImagesController.cs +++ b/ChatService.Web/Controllers/ImagesController.cs @@ -1,5 +1,6 @@ using ChatService.Web.Dtos; -using ChatService.Web.Storage; +using ChatService.Web.Exceptions; +using ChatService.Web.Services; using Microsoft.AspNetCore.Mvc; namespace ChatService.Web.Controllers; @@ -8,40 +9,49 @@ namespace ChatService.Web.Controllers; [Route("api/[controller]")] public class ImagesController : ControllerBase { - private readonly IImageStore _imageStore; + private readonly IImageService _imageService; - public ImagesController(IImageStore imageStore) + public ImagesController(IImageService imageService) { - _imageStore = imageStore; + _imageService = imageService; } [HttpPost] public async Task> UploadImage([FromForm] UploadImageRequest request) { - string contentType = request.File.ContentType.ToLower(); - if (contentType != "image/jpg" && - contentType != "image/jpeg" && - contentType != "image/png") - { - return BadRequest($"Invalid file, must be an image."); - } - MemoryStream content = new(); await request.File.CopyToAsync(content); - Image image = new Image(contentType, content); + Image image = new Image(request.File.ContentType, content); + + UploadImageServiceResult result; - string imageId = await _imageStore.UploadImage(image); - return CreatedAtAction(nameof(DownloadImage), new { id = imageId }, new UploadImageResponse(imageId)); + try + { + result = await _imageService.UploadImage(image); + } + catch (InvalidImageTypeException e) + { + return BadRequest(e.Message); + } + + return CreatedAtAction(nameof(DownloadImage), new { imageId = result.ImageId }, + new UploadImageResponse(result.ImageId)); } - [HttpGet("{id}")] - public async Task DownloadImage(string id) + [HttpGet("{imageId}")] + public async Task DownloadImage(string imageId) { - Image? image = await _imageStore.DownloadImage(id); - if (image == null) + try + { + return await _imageService.DownloadImage(imageId); + } + catch (ArgumentException e) + { + return BadRequest(e.Message); + } + catch (ImageNotFoundException e) { - return NotFound($"An image with id {id} was not found."); + return NotFound(e.Message); } - return new FileContentResult(image.Content.ToArray(), image.ContentType); } } \ 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/Program.cs b/ChatService.Web/Program.cs index 0d80c49..a6a16a3 100644 --- a/ChatService.Web/Program.cs +++ b/ChatService.Web/Program.cs @@ -29,6 +29,7 @@ } ); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/ChatService.Web/Services/ImageService.cs b/ChatService.Web/Services/ImageService.cs index 41ad565..f8b5082 100644 --- a/ChatService.Web/Services/ImageService.cs +++ b/ChatService.Web/Services/ImageService.cs @@ -1,4 +1,5 @@ using ChatService.Web.Dtos; +using ChatService.Web.Exceptions; using ChatService.Web.Storage; using Microsoft.AspNetCore.Mvc; @@ -13,13 +14,46 @@ public ImageService(IImageStore imageStore) _imageStore = imageStore; } - public Task UploadImage(Image image) + public async Task UploadImage(Image image) { - throw new NotImplementedException(); + ValidateImage(image); + + string imageId = await _imageStore.UploadImage(image); + + return new UploadImageServiceResult(imageId); } - public Task DownloadImage(string imageId) + public async Task DownloadImage(string imageId) { - throw new NotImplementedException(); + ValidateImageId(imageId); + + Image? image = await _imageStore.DownloadImage(imageId); + + if (image == null) + { + throw new ImageNotFoundException($"An image with id {imageId} was not found."); + } + + return new FileContentResult(image.Content.ToArray(), image.ContentType); + } + + 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.sln.DotSettings.user b/ChatService.sln.DotSettings.user index d7d0414..aa1bce9 100644 --- a/ChatService.sln.DotSettings.user +++ b/ChatService.sln.DotSettings.user @@ -3,24 +3,18 @@ On On On - C:\Users\nadim\AppData\Local\JetBrains\Rider2022.3\resharper-host\temp\Rider\vAny\CoverageData\_ChatService.34854384\Snapshot\snapshot.utdcvr + <SessionState ContinuousTestingMode="0" Name="GetUserConversations_Ordered_Successful" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <TestAncestor> <TestId>xUnit::A7808CB8-B561-4939-9B4E-8AC0FD786DC3::net6.0::ChatService.Web.IntegrationTests.CosmosConversationStoreTests</TestId> <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ImageControllerTests</TestId> </TestAncestor> </SessionState> - <SessionState ContinuousTestingMode="0" IsActive="True" Name="ImageControllerTests" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <SessionState ContinuousTestingMode="0" Name="ImageControllerTests" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> </SessionState> - <SessionState ContinuousTestingMode="0" Name="ProfileControllerTests" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <TestAncestor> - <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ProfileControllerTests</TestId> - <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Services.ProfileServiceTests</TestId> - <TestId>xUnit::A7808CB8-B561-4939-9B4E-8AC0FD786DC3::net6.0::ChatService.Web.IntegrationTests.CosmosMessageStoreTests.AddMessage_Successful</TestId> - <TestId>xUnit::A7808CB8-B561-4939-9B4E-8AC0FD786DC3::net6.0::ChatService.Web.IntegrationTests.CosmosMessageStoreTests.AddMessage_InvalidArguments</TestId> - <TestId>xUnit::A7808CB8-B561-4939-9B4E-8AC0FD786DC3::net6.0::ChatService.Web.IntegrationTests.CosmosMessageStoreTests</TestId> - </TestAncestor> + <SessionState ContinuousTestingMode="0" IsActive="True" Name="ProfileControllerTests" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Solution /> </SessionState> <SessionState ContinuousTestingMode="0" Name="CosmosMessageStoreTests" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <TestAncestor> From 31dab5f94257b9888a87c00948af7775f1eb5e19 Mon Sep 17 00:00:00 2001 From: nadimakk Date: Thu, 30 Mar 2023 23:18:47 +0300 Subject: [PATCH 35/45] refactoring of CosmosProfileStoreTest --- .../CosmosProfileStoreTests.cs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs b/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs index aadea9f..993b0a1 100644 --- a/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs +++ b/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs @@ -10,12 +10,13 @@ public class CosmosProfileStoreTest : IClassFixture Date: Fri, 31 Mar 2023 01:15:43 +0300 Subject: [PATCH 36/45] ConversationsControllerTests --- .../ConversationsControllerTests.cs | 324 ++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100644 ChatService.Web.Tests/Controllers/ConversationsControllerTests.cs diff --git a/ChatService.Web.Tests/Controllers/ConversationsControllerTests.cs b/ChatService.Web.Tests/Controllers/ConversationsControllerTests.cs new file mode 100644 index 0000000..da8fa2c --- /dev/null +++ b/ChatService.Web.Tests/Controllers/ConversationsControllerTests.cs @@ -0,0 +1,324 @@ +using System.Net; +using System.Net.Http.Json; +using ChatService.Web.Dtos; +using ChatService.Web.Enums; +using ChatService.Web.Exceptions; +using ChatService.Web.Services; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Newtonsoft.Json; + +namespace ChatService.Web.Tests.Controllers; + +public class ConversationsControllerTests : IClassFixture> +{ + private readonly HttpClient _httpClient; + private readonly Mock _userConversationServiceMock = new(); + private readonly Mock _messageServiceMock = new(); + + private static readonly string _username = Guid.NewGuid().ToString(); + + private static readonly SendMessageRequest _sendMessageRequest = new SendMessageRequest + { + MessageId = Guid.NewGuid().ToString(), + SenderUsername = _username, + Text = "Hello" + }; + + private readonly string _conversationId = Guid.NewGuid().ToString(); + + private readonly long _unixTimeNow = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + private readonly StartConversationRequest _startConversationRequest = new StartConversationRequest + { + Participants = new List { _username, Guid.NewGuid().ToString() }, + FirstMessage = _sendMessageRequest + }; + + public ConversationsControllerTests(WebApplicationFactory factory) + { + _httpClient = factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddSingleton(_userConversationServiceMock.Object); + services.AddSingleton(_messageServiceMock.Object); + }); + }).CreateClient(); + } + + [Fact] + public async Task GetUserConversations_Success() + { + List conversations = new(); + conversations.Add(new Conversation + { + ConversationId = Guid.NewGuid().ToString(), + LastModifiedUnixTime = _unixTimeNow + }); + conversations.Add(new Conversation + { + ConversationId = Guid.NewGuid().ToString(), + LastModifiedUnixTime = _unixTimeNow + }); + + string nextContinuationToken = Guid.NewGuid().ToString(); + + var userConversationServiceResult = new GetUserConversationsServiceResult + { + Conversations = conversations, + NextContinuationToken = nextContinuationToken + }; + + _userConversationServiceMock.Setup(m => m.GetUserConversations(_username, 10, OrderBy.DESC, null, 0)) + .ReturnsAsync(userConversationServiceResult); + + string nextUri = "/api/conversations" + + $"?username={_username}" + + "&limit=10" + + "&lastSeenConversationTime=0" + + $"&continuationToken={nextContinuationToken}"; + + var response = await _httpClient.GetAsync($"api/Conversations/?username={_username}"); + var json = await response.Content.ReadAsStringAsync(); + var receivedGetUserConversationsResponse = JsonConvert.DeserializeObject(json); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(conversations, receivedGetUserConversationsResponse.Conversations); + Assert.Equal(nextUri, receivedGetUserConversationsResponse.NextUri); + } + + [Fact] + public async Task GetUserConversations_InvalidArguments() + { + _userConversationServiceMock.Setup(m => m.GetUserConversations(_username, 10, OrderBy.DESC, null, 0)) + .ThrowsAsync(new ArgumentException($"Invalid arguments.")); + + var response = await _httpClient.GetAsync($"api/Conversations/?username={_username}"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GetUserConversations_UserNotFound() + { + _userConversationServiceMock.Setup(m => m.GetUserConversations(_username, 10, OrderBy.DESC, null, 0)) + .ThrowsAsync(new UserNotFoundException($"User {_username} was not found.")); + + var response = await _httpClient.GetAsync($"api/Conversations/?username={_username}"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task StartConversation_Success() + { + var startConversationResponse = new StartConversationResponse + { + ConversationId = Guid.NewGuid().ToString(), + CreatedUnixTime = _unixTimeNow + }; + + _userConversationServiceMock.Setup(m => m.CreateConversation(It.Is( + p => p.Participants.SequenceEqual(_startConversationRequest.Participants) + && p.FirstMessage == _startConversationRequest.FirstMessage))) + .ReturnsAsync(startConversationResponse); + + 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(startConversationResponse, receivedStartConversationResponse); + } + + [Fact] + public async Task StartConversation_InvalidArguments() + { + _userConversationServiceMock.Setup(m => m.CreateConversation(It.Is( + p => p.Participants.SequenceEqual(_startConversationRequest.Participants) + && p.FirstMessage == _startConversationRequest.FirstMessage))) + .ThrowsAsync(new ArgumentException()); + + var response = await _httpClient.PostAsJsonAsync($"api/Conversations/", _startConversationRequest); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task StartConversation_ProfileNotFound() + { + _userConversationServiceMock.Setup(m => m.CreateConversation(It.Is( + p => p.Participants.SequenceEqual(_startConversationRequest.Participants) + && p.FirstMessage == _startConversationRequest.FirstMessage))) + .ThrowsAsync(new ProfileNotFoundException($"A profile with the username {_username} was not found.")); + + var response = await _httpClient.PostAsJsonAsync($"api/Conversations/", _startConversationRequest); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task StartConversation_MessageExists() + { + _userConversationServiceMock.Setup(m => m.CreateConversation(It.Is( + p => p.Participants.SequenceEqual(_startConversationRequest.Participants) + && p.FirstMessage == _startConversationRequest.FirstMessage))) + .ThrowsAsync(new MessageExistsException( + $"A message with ID {_startConversationRequest.FirstMessage.MessageId} already exists.")); + + var response = await _httpClient.PostAsJsonAsync($"api/Conversations/", _startConversationRequest); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + [Fact] + public async Task GetMessages_Success() + { + List messages = new(); + messages.Add(new Message + { + MessageId = Guid.NewGuid().ToString(), + SenderUsername = Guid.NewGuid().ToString(), + UnixTime = _unixTimeNow + }); + messages.Add(new Message + { + MessageId = Guid.NewGuid().ToString(), + SenderUsername = Guid.NewGuid().ToString(), + UnixTime = _unixTimeNow + }); + + string nextContinuationToken = Guid.NewGuid().ToString(); + + var getMessagesServiceResult = new GetMessagesServiceResult + { + Messages = messages, + NextContinuationToken = nextContinuationToken + }; + + _messageServiceMock.Setup(m => m.GetMessages(_conversationId, 10, OrderBy.DESC, null, 0)) + .ReturnsAsync(getMessagesServiceResult); + + string nextUri = $"/api/conversations/{_conversationId}/messages" + + "&limit=10" + + $"&continuationToken={nextContinuationToken}" + + "&lastSeenConversationTime=0"; + + var response = await _httpClient.GetAsync($"/api/conversations/{_conversationId}/messages/"); + var json = await response.Content.ReadAsStringAsync(); + var receivedGetMessagesResponse = JsonConvert.DeserializeObject(json); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(messages, receivedGetMessagesResponse.Messages); + Assert.Equal(nextUri, receivedGetMessagesResponse.NextUri); + } + + [Fact] + public async Task GetMessages_InvalidArguments() + { + _messageServiceMock.Setup(m => m.GetMessages(_conversationId, 10, OrderBy.DESC, null, 0)) + .ThrowsAsync(new ArgumentException()); + + var response = await _httpClient.GetAsync($"/api/conversations/{_conversationId}/messages/"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GetMessages_ConversationDoesNotExist() + { + _messageServiceMock.Setup(m => m.GetMessages(_conversationId, 10, OrderBy.DESC, null, 0)) + .ThrowsAsync(new ConversationDoesNotExistException( + $"A conversation partition with the conversationId {_conversationId} does not exist.")); + + var response = await _httpClient.GetAsync($"/api/conversations/{_conversationId}/messages/"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task PostMessage_Success() + { + var sendMessageResponse = new SendMessageResponse + { + CreatedUnixTime = _unixTimeNow + }; + + _messageServiceMock.Setup(m => m.AddMessage(_conversationId, false, _sendMessageRequest)) + .ReturnsAsync(sendMessageResponse); + + var response = await _httpClient.PostAsJsonAsync( + $"/api/conversations/{_conversationId}/messages/", _sendMessageRequest); + var json = await response.Content.ReadAsStringAsync(); + var receivedSendMessageResponse = JsonConvert.DeserializeObject(json); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.Equal(sendMessageResponse, receivedSendMessageResponse); + } + + [Fact] + public async Task PostMessage_InvalidArguments() + { + _messageServiceMock.Setup(m => m.AddMessage(_conversationId, false, _sendMessageRequest)) + .ThrowsAsync(new ArgumentException()); + + var response = await _httpClient.PostAsJsonAsync( + $"/api/conversations/{_conversationId}/messages/", _sendMessageRequest); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PostMessage_UserNotParticipantException() + { + _messageServiceMock.Setup(m => m.AddMessage(_conversationId, false, _sendMessageRequest)) + .ThrowsAsync(new UserNotParticipantException( + $"User {_username} is not a participant of conversation {_conversationId}.")); + + var response = await _httpClient.PostAsJsonAsync( + $"/api/conversations/{_conversationId}/messages/", _sendMessageRequest); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task PostMessage_ProfileNotFound() + { + _messageServiceMock.Setup(m => m.AddMessage(_conversationId, false, _sendMessageRequest)) + .ThrowsAsync(new ProfileNotFoundException( + $"A profile with the username {_username} was not found.")); + + var response = await _httpClient.PostAsJsonAsync( + $"/api/conversations/{_conversationId}/messages/", _sendMessageRequest); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task PostMessage_ConversationDoesNotExistException() + { + _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_MessageExistsException() + { + _messageServiceMock.Setup(m => m.AddMessage(_conversationId, false, _sendMessageRequest)) + .ThrowsAsync(new MessageExistsException($"A message with ID {_sendMessageRequest.MessageId} already exists.")); + + var response = await _httpClient.PostAsJsonAsync( + $"/api/conversations/{_conversationId}/messages/", _sendMessageRequest); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } +} \ No newline at end of file From 8fbb5a5673e7b62e7d714aff14c2876fbaf19c63 Mon Sep 17 00:00:00 2001 From: Ali Date: Fri, 31 Mar 2023 01:20:14 +0300 Subject: [PATCH 37/45] Tiny bug fix --- .../CosmosProfileStoreTests.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs b/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs index 993b0a1..3395041 100644 --- a/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs +++ b/ChatService.Web.IntegrationTests/CosmosProfileStoreTests.cs @@ -55,7 +55,14 @@ public async Task AddNewProfile_Success() [InlineData("foobar", "Foo", "Bar"," ")] public async Task AddNewProfile_InvalidArgs(string username, string firstName, string lastName, string profilePictureId) { - Profile profile = new(username, firstName, lastName, profilePictureId); + Profile profile = new Profile + { + Username = username, + FirstName = firstName, + LastName = lastName, + ProfilePictureId = profilePictureId + }; + await Assert.ThrowsAsync( async () => await _profileStore.AddProfile(profile)); } From f8fc10d5fd481db6a166d3c43f560d15a027d7a7 Mon Sep 17 00:00:00 2001 From: Ali Date: Fri, 31 Mar 2023 10:56:33 +0300 Subject: [PATCH 38/45] Implement ImageServiceTests and refactor --- .../ConversationsControllerTests.cs | 22 +++-- .../Controllers/ImagesControllerTests.cs | 68 +++++++-------- .../Services/ImageServiceTests.cs | 87 +++++++++++++++++++ .../Controllers/ImagesController.cs | 9 +- ChatService.sln.DotSettings.user | 31 ++----- 5 files changed, 139 insertions(+), 78 deletions(-) create mode 100644 ChatService.Web.Tests/Services/ImageServiceTests.cs diff --git a/ChatService.Web.Tests/Controllers/ConversationsControllerTests.cs b/ChatService.Web.Tests/Controllers/ConversationsControllerTests.cs index da8fa2c..3ae8f04 100644 --- a/ChatService.Web.Tests/Controllers/ConversationsControllerTests.cs +++ b/ChatService.Web.Tests/Controllers/ConversationsControllerTests.cs @@ -30,6 +30,8 @@ public class ConversationsControllerTests : IClassFixture m.GetUserConversations(_username, 10, OrderBy.DESC, null, 0)) @@ -79,7 +79,7 @@ public async Task GetUserConversations_Success() $"?username={_username}" + "&limit=10" + "&lastSeenConversationTime=0" + - $"&continuationToken={nextContinuationToken}"; + $"&continuationToken={_nextContinuationToken}"; var response = await _httpClient.GetAsync($"api/Conversations/?username={_username}"); var json = await response.Content.ReadAsStringAsync(); @@ -190,13 +190,11 @@ public async Task GetMessages_Success() SenderUsername = Guid.NewGuid().ToString(), UnixTime = _unixTimeNow }); - - string nextContinuationToken = Guid.NewGuid().ToString(); - + var getMessagesServiceResult = new GetMessagesServiceResult { Messages = messages, - NextContinuationToken = nextContinuationToken + NextContinuationToken = _nextContinuationToken }; _messageServiceMock.Setup(m => m.GetMessages(_conversationId, 10, OrderBy.DESC, null, 0)) @@ -204,7 +202,7 @@ public async Task GetMessages_Success() string nextUri = $"/api/conversations/{_conversationId}/messages" + "&limit=10" + - $"&continuationToken={nextContinuationToken}" + + $"&continuationToken={_nextContinuationToken}" + "&lastSeenConversationTime=0"; var response = await _httpClient.GetAsync($"/api/conversations/{_conversationId}/messages/"); @@ -272,7 +270,7 @@ public async Task PostMessage_InvalidArguments() } [Fact] - public async Task PostMessage_UserNotParticipantException() + public async Task PostMessage_UserNotParticipant() { _messageServiceMock.Setup(m => m.AddMessage(_conversationId, false, _sendMessageRequest)) .ThrowsAsync(new UserNotParticipantException( @@ -298,7 +296,7 @@ public async Task PostMessage_ProfileNotFound() } [Fact] - public async Task PostMessage_ConversationDoesNotExistException() + public async Task PostMessage_ConversationDoesNotExist() { _messageServiceMock.Setup(m => m.AddMessage(_conversationId, false, _sendMessageRequest)) .ThrowsAsync(new ConversationDoesNotExistException( @@ -311,7 +309,7 @@ public async Task PostMessage_ConversationDoesNotExistException() } [Fact] - public async Task PostMessage_MessageExistsException() + public async Task PostMessage_MessageExists() { _messageServiceMock.Setup(m => m.AddMessage(_conversationId, false, _sendMessageRequest)) .ThrowsAsync(new MessageExistsException($"A message with ID {_sendMessageRequest.MessageId} already exists.")); diff --git a/ChatService.Web.Tests/Controllers/ImagesControllerTests.cs b/ChatService.Web.Tests/Controllers/ImagesControllerTests.cs index ae87d19..5ae893a 100644 --- a/ChatService.Web.Tests/Controllers/ImagesControllerTests.cs +++ b/ChatService.Web.Tests/Controllers/ImagesControllerTests.cs @@ -1,6 +1,5 @@ using System.Net; using System.Net.Http.Headers; -using System.Text; using ChatService.Web.Dtos; using ChatService.Web.Exceptions; using ChatService.Web.Services; @@ -17,8 +16,18 @@ public class ImagesControllerTests : IClassFixture _imageServiceMock = new(); private readonly HttpClient _httpClient; + + private static readonly Image _image = new("image/jpeg", new MemoryStream()); + private readonly MultipartFormDataContent _content = new(); + private readonly StreamContent _fileContent = new StreamContent(_image.Content) + { + Headers = { ContentType = new MediaTypeHeaderValue(_image.ContentType) } + }; + + private readonly string _imageId = Guid.NewGuid().ToString(); + public ImagesControllerTests(WebApplicationFactory factory) { _httpClient = factory.WithWebHostBuilder(builder => @@ -30,25 +39,22 @@ public ImagesControllerTests(WebApplicationFactory factory) [Fact] public async Task UploadImage_Success() { - var image = new Image("image/jpeg", new MemoryStream()); - var imageId = Guid.NewGuid().ToString(); - var uploadImageResponse = new UploadImageResponse(imageId); + var uploadImageResponse = new UploadImageResponse(_imageId); _imageServiceMock.Setup(m => m.UploadImage(It.IsAny())) - .ReturnsAsync(new UploadImageServiceResult(imageId)); + .ReturnsAsync(new UploadImageServiceResult(_imageId)); - var fileContent = new StreamContent(image.Content); - fileContent.Headers.ContentType = new MediaTypeHeaderValue(image.ContentType); - _content.Add(fileContent,"File", "image.jpeg"); + _content.Add(_fileContent,"File", "image.jpeg"); var response = await _httpClient.PostAsync("api/Images/", _content); Assert.Equal(HttpStatusCode.Created, response.StatusCode); - - Assert.Equal($"http://localhost/api/Images/{imageId}", response.Headers.GetValues("Location").First()); + + Assert.Equal($"http://localhost/api/Images/{_imageId}", response.Headers.GetValues("Location").First()); var json = await response.Content.ReadAsStringAsync(); var receivedUploadImageResponse = JsonConvert.DeserializeObject(json); + Assert.Equal(uploadImageResponse, receivedUploadImageResponse); } @@ -63,41 +69,33 @@ public async Task UploadImage_MissingFile() } [Fact] - public async Task UploadImage_InvalidFile() + public async Task UploadImage_InvalidImageType() { - var content = new MemoryStream(Encoding.UTF8.GetBytes("This is a mock text file content")); - var contentType = "text/plain"; - var fileContent = new StreamContent(content); - fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain"); - _content.Add(fileContent,"File", "file.txt"); - _imageServiceMock.Setup(m => m.UploadImage(It.IsAny())) - .ThrowsAsync(new InvalidImageTypeException($"Invalid image type {contentType}.")); + .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); - - var json = await response.Content.ReadAsStringAsync(); - Assert.Equal($"Invalid image type {contentType}.", json); } [Fact] public async Task DownloadImage_Success() { - var imageId = Guid.NewGuid().ToString(); - var image = new Image("image/jpeg", new MemoryStream()); - var fileContentResult = new FileContentResult(image.Content.ToArray(), image.ContentType); + var fileContentResult = new FileContentResult(_image.Content.ToArray(), _image.ContentType); - _imageServiceMock.Setup(m => m.DownloadImage(imageId)) - .ReturnsAsync(new FileContentResult(image.Content.ToArray(), image.ContentType)); + _imageServiceMock.Setup(m => m.DownloadImage(_imageId)) + .ReturnsAsync(fileContentResult); - var response = await _httpClient.GetAsync($"api/Images/{imageId}"); + var response = await _httpClient.GetAsync($"api/Images/{_imageId}"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content.Headers.ContentType); - var content = await response.Content.ReadAsByteArrayAsync(); var contentType = response.Content.Headers.ContentType.ToString(); + var content = await response.Content.ReadAsByteArrayAsync(); Assert.Equal(fileContentResult.FileContents, content); Assert.Equal(fileContentResult.ContentType, contentType); @@ -106,12 +104,10 @@ public async Task DownloadImage_Success() [Fact] public async Task DownloadImage_NotFound() { - var imageId = Guid.NewGuid().ToString(); - - _imageServiceMock.Setup(m => m.DownloadImage(imageId)) - .ThrowsAsync( new ImageNotFoundException($"An image with id {imageId} was not found.")); + _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}"); + var response = await _httpClient.GetAsync($"api/Images/{_imageId}"); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } @@ -119,12 +115,10 @@ public async Task DownloadImage_NotFound() [Fact] public async Task DownloadImage_InvalidArgument() { - var imageId = Guid.NewGuid().ToString(); - - _imageServiceMock.Setup(m => m.DownloadImage(imageId)) + _imageServiceMock.Setup(m => m.DownloadImage(_imageId)) .ThrowsAsync( new ArgumentException("Invalid imageId")); - var response = await _httpClient.GetAsync($"api/Images/{imageId}"); + var response = await _httpClient.GetAsync($"api/Images/{_imageId}"); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } diff --git a/ChatService.Web.Tests/Services/ImageServiceTests.cs b/ChatService.Web.Tests/Services/ImageServiceTests.cs new file mode 100644 index 0000000..4c14729 --- /dev/null +++ b/ChatService.Web.Tests/Services/ImageServiceTests.cs @@ -0,0 +1,87 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Exceptions; +using ChatService.Web.Services; +using ChatService.Web.Storage; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace ChatService.Web.Tests.Services; + +public class ImageServiceTests : IClassFixture> +{ + private readonly Mock _imageStoreMock = new(); + private readonly IImageService _imageService; + + private readonly string _imageId = Guid.NewGuid().ToString(); + private readonly Image _image = new("image/jpeg", new MemoryStream(new byte[] { 0x01, 0x02, 0x03 })); + + public ImageServiceTests(WebApplicationFactory factory) + { + _imageService = factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddSingleton(_imageStoreMock.Object); + }); + }).Services.GetRequiredService(); + } + + [Fact] + public async Task UploadImage_Success() + { + _imageStoreMock.Setup(m => m.UploadImage(It.IsAny())) + .ReturnsAsync(_imageId); + + var expectedUploadImageServiceResult = new UploadImageServiceResult(_imageId); + + var receivedUploadImageServiceResult = await _imageService.UploadImage(_image); + + Assert.Equal(expectedUploadImageServiceResult, receivedUploadImageServiceResult); + } + + [Fact] + public async Task UploadImage_InvalidImageType() + { + var invalidImage = new Image("text/plain", new MemoryStream()); + + await Assert.ThrowsAsync(() => _imageService.UploadImage(invalidImage)); + + _imageStoreMock.Verify(m => m.UploadImage(It.IsAny()), Times.Never); + } + + [Fact] + public async Task DownloadImage_Success() + { + var expectedFileContentResult = new FileContentResult(_image.Content.ToArray(), _image.ContentType); + + _imageStoreMock.Setup(m => m.DownloadImage(_imageId)) + .ReturnsAsync(_image); + + var receivedFileContentResult = await _imageService.DownloadImage(_imageId); + + Assert.Equal(expectedFileContentResult.ContentType, receivedFileContentResult.ContentType); + Assert.True(expectedFileContentResult.FileContents.SequenceEqual(receivedFileContentResult.FileContents)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task DownloadImage_InvalidArguments(string imageId) + { + + await Assert.ThrowsAsync(() => _imageService.DownloadImage(imageId)); + } + + [Fact] + public async Task DownloadImage_ImageNotFound() + { + _imageStoreMock.Setup(m => m.DownloadImage(_imageId)) + .ReturnsAsync((Image?)null); + + await Assert.ThrowsAsync(() => _imageService.DownloadImage(_imageId)); + } +} \ No newline at end of file diff --git a/ChatService.Web/Controllers/ImagesController.cs b/ChatService.Web/Controllers/ImagesController.cs index 68c6952..c5345a6 100644 --- a/ChatService.Web/Controllers/ImagesController.cs +++ b/ChatService.Web/Controllers/ImagesController.cs @@ -22,20 +22,17 @@ public async Task> UploadImage([FromForm] Uplo MemoryStream content = new(); await request.File.CopyToAsync(content); Image image = new Image(request.File.ContentType, content); - - UploadImageServiceResult result; try { - result = await _imageService.UploadImage(image); + UploadImageServiceResult result = await _imageService.UploadImage(image); + return CreatedAtAction(nameof(DownloadImage), new { imageId = result.ImageId }, + new UploadImageResponse(result.ImageId)); } catch (InvalidImageTypeException e) { return BadRequest(e.Message); } - - return CreatedAtAction(nameof(DownloadImage), new { imageId = result.ImageId }, - new UploadImageResponse(result.ImageId)); } [HttpGet("{imageId}")] diff --git a/ChatService.sln.DotSettings.user b/ChatService.sln.DotSettings.user index d7d0414..2464955 100644 --- a/ChatService.sln.DotSettings.user +++ b/ChatService.sln.DotSettings.user @@ -3,35 +3,20 @@ On On On - C:\Users\nadim\AppData\Local\JetBrains\Rider2022.3\resharper-host\temp\Rider\vAny\CoverageData\_ChatService.34854384\Snapshot\snapshot.utdcvr - <SessionState ContinuousTestingMode="0" Name="GetUserConversations_Ordered_Successful" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <SessionState ContinuousTestingMode="0" Name="ImagesControllerTests" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <TestAncestor> - <TestId>xUnit::A7808CB8-B561-4939-9B4E-8AC0FD786DC3::net6.0::ChatService.Web.IntegrationTests.CosmosConversationStoreTests</TestId> - <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ImageControllerTests</TestId> + <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ImagesControllerTests</TestId> </TestAncestor> </SessionState> - <SessionState ContinuousTestingMode="0" IsActive="True" Name="ImageControllerTests" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <Solution /> -</SessionState> - <SessionState ContinuousTestingMode="0" Name="ProfileControllerTests" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <TestAncestor> - <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ProfileControllerTests</TestId> - <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Services.ProfileServiceTests</TestId> - <TestId>xUnit::A7808CB8-B561-4939-9B4E-8AC0FD786DC3::net6.0::ChatService.Web.IntegrationTests.CosmosMessageStoreTests.AddMessage_Successful</TestId> - <TestId>xUnit::A7808CB8-B561-4939-9B4E-8AC0FD786DC3::net6.0::ChatService.Web.IntegrationTests.CosmosMessageStoreTests.AddMessage_InvalidArguments</TestId> - <TestId>xUnit::A7808CB8-B561-4939-9B4E-8AC0FD786DC3::net6.0::ChatService.Web.IntegrationTests.CosmosMessageStoreTests</TestId> - </TestAncestor> -</SessionState> - <SessionState ContinuousTestingMode="0" Name="CosmosMessageStoreTests" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <TestAncestor> - <TestId>xUnit::A7808CB8-B561-4939-9B4E-8AC0FD786DC3::net6.0::ChatService.Web.IntegrationTests.CosmosMessageStoreTests</TestId> - <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Services.ProfileServiceTests</TestId> - </TestAncestor> -</SessionState> - <SessionState ContinuousTestingMode="0" Name="Session" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + + + + <SessionState ContinuousTestingMode="0" IsActive="True" Name="ProfileControllerTests" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> </SessionState> + + DefaultEndpointsProtocol=https;AccountName=naa137;AccountKey=p6VJPQjkAPm3/JxBIY1l7Yj9ewb4Fp2l+Aciwm969OgT57+70hT/jMKcyDIdIwIJaEijfqsxAOdf+AStE/MZDw==;EndpointSuffix=core.windows.net From 26ab01fef30dd70b3a73aa2d93fb19ce84272550 Mon Sep 17 00:00:00 2001 From: Nadim Akkaoui Date: Fri, 31 Mar 2023 16:43:24 +0300 Subject: [PATCH 39/45] Code Refactor --- .../ConversationsControllerTests.cs | 12 +- .../Services/MessageServiceTests.cs | 206 ++++++++++++++++++ .../Services/UserConversationServiceTests.cs | 22 +- .../Controllers/ConversationsController.cs | 53 +++-- .../Dtos/StartConversationServiceResult.cs | 9 + ChatService.Web/Program.cs | 4 - ChatService.Web/Services/IImageService.cs | 2 + .../Services/IUserConversationService.cs | 2 +- ChatService.Web/Services/ImageService.cs | 10 + ChatService.Web/Services/MessageService.cs | 98 +++++---- ChatService.Web/Services/ProfileService.cs | 62 +++--- .../Services/UserConversationService.cs | 84 ++++--- .../Storage/IUserConversationStore.cs | 1 - .../Utilities/ConversationIdUtilities.cs | 14 ++ ChatService.sln.DotSettings.user | 8 +- 15 files changed, 416 insertions(+), 171 deletions(-) create mode 100644 ChatService.Web.Tests/Services/MessageServiceTests.cs create mode 100644 ChatService.Web/Dtos/StartConversationServiceResult.cs create mode 100644 ChatService.Web/Utilities/ConversationIdUtilities.cs diff --git a/ChatService.Web.Tests/Controllers/ConversationsControllerTests.cs b/ChatService.Web.Tests/Controllers/ConversationsControllerTests.cs index 3ae8f04..2635c49 100644 --- a/ChatService.Web.Tests/Controllers/ConversationsControllerTests.cs +++ b/ChatService.Web.Tests/Controllers/ConversationsControllerTests.cs @@ -115,7 +115,7 @@ public async Task GetUserConversations_UserNotFound() [Fact] public async Task StartConversation_Success() { - var startConversationResponse = new StartConversationResponse + var startConversationServiceResult = new StartConversationServiceResult { ConversationId = Guid.NewGuid().ToString(), CreatedUnixTime = _unixTimeNow @@ -124,14 +124,20 @@ public async Task StartConversation_Success() _userConversationServiceMock.Setup(m => m.CreateConversation(It.Is( p => p.Participants.SequenceEqual(_startConversationRequest.Participants) && p.FirstMessage == _startConversationRequest.FirstMessage))) - .ReturnsAsync(startConversationResponse); + .ReturnsAsync(startConversationServiceResult); + + var expectedStartConversationResponse = new StartConversationResponse + { + ConversationId = startConversationServiceResult.ConversationId, + CreatedUnixTime = startConversationServiceResult.CreatedUnixTime + }; var response = await _httpClient.PostAsJsonAsync($"api/Conversations/", _startConversationRequest); var json = await response.Content.ReadAsStringAsync(); var receivedStartConversationResponse = JsonConvert.DeserializeObject(json); Assert.Equal(HttpStatusCode.Created, response.StatusCode); - Assert.Equal(startConversationResponse, receivedStartConversationResponse); + Assert.Equal(expectedStartConversationResponse, receivedStartConversationResponse); } [Fact] diff --git a/ChatService.Web.Tests/Services/MessageServiceTests.cs b/ChatService.Web.Tests/Services/MessageServiceTests.cs new file mode 100644 index 0000000..1ff713b --- /dev/null +++ b/ChatService.Web.Tests/Services/MessageServiceTests.cs @@ -0,0 +1,206 @@ +using ChatService.Web.Dtos; +using ChatService.Web.Enums; +using ChatService.Web.Exceptions; +using ChatService.Web.Services; +using ChatService.Web.Storage; +using ChatService.Web.Utilities; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace ChatService.Web.Tests.Services; + +public class MessageServiceTests : IClassFixture> +{ + private readonly Mock _messageStoreMock = new(); + private readonly Mock _profileServiceMock = new(); + private readonly IMessageService _messageService; + + private static readonly string _senderUsername = Guid.NewGuid().ToString(); + + private static readonly string _recipientUsername = Guid.NewGuid().ToString(); + + private static readonly string _conversationId = ConversationIdUtilities.GenerateConversationId(_senderUsername, _recipientUsername); + + private readonly long _unixTimeNow = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + private readonly SendMessageRequest _sendMessageRequest = new SendMessageRequest + { + MessageId = Guid.NewGuid().ToString(), + SenderUsername = _senderUsername, + Text = "Hello" + }; + + public MessageServiceTests(WebApplicationFactory factory) + { + _messageService = factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddSingleton(_messageStoreMock.Object); + services.AddSingleton(_profileServiceMock.Object); + }); + }).Services.GetRequiredService(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task AddMessage_Success(bool isFirstMessage) + { + _profileServiceMock.Setup(m => m.ProfileExists(_senderUsername)) + .ReturnsAsync(true); + + _messageStoreMock.Setup(m => m.ConversationPartitionExists(_conversationId)) + .ReturnsAsync(true); + + Message message = new Message + { + MessageId = _sendMessageRequest.MessageId, + UnixTime = _unixTimeNow, + SenderUsername = _sendMessageRequest.SenderUsername, + Text = _sendMessageRequest.Text + }; + + SendMessageResponse expectedSendMessageResponse = new SendMessageResponse + { + CreatedUnixTime = _unixTimeNow + }; + + SendMessageResponse receivedSendMessageResponse = await _messageService.AddMessage( + _conversationId, isFirstMessage, _sendMessageRequest); + + _messageStoreMock.Verify(m => m.AddMessage(_conversationId, It.Is( + m => m.MessageId == message.MessageId + && m.SenderUsername == message.SenderUsername + && m.Text == message.Text)), Times.Once); + + receivedSendMessageResponse.CreatedUnixTime = _unixTimeNow; + + Assert.Equal(expectedSendMessageResponse, receivedSendMessageResponse); + } + + [Theory] + [InlineData(null, "messageId", "senderUsername", "text")] + [InlineData("", "messageId", "senderUsername", "text")] + [InlineData(" ", "messageId", "senderUsername", "text")] + [InlineData("conversationId", null, "senderUsername", "text")] + [InlineData("conversationId", "", "senderUsername", "text")] + [InlineData("conversationId", " ", "senderUsername", "text")] + [InlineData("conversationId", "messageId", null, "text")] + [InlineData("conversationId", "messageId", "", "text")] + [InlineData("conversationId", "messageId", " ", "text")] + [InlineData("conversationId", "messageId", "senderUsername", null)] + [InlineData("conversationId", "messageId", "senderUsername", "")] + [InlineData("conversationId", "messageId", "senderUsername", " ")] + public async Task AddMessage_InvalidArguments( + string conversationId, string messageId, string senderUsername, string text) + { + SendMessageRequest sendMessageRequest = new SendMessageRequest + { + MessageId = messageId, + SenderUsername = senderUsername, + Text = text + }; + + await Assert.ThrowsAsync(() => _messageService.AddMessage( + conversationId, true, sendMessageRequest)); + } + + [Fact] + public async Task AddMessage_UserNotParticipant() + { + _sendMessageRequest.SenderUsername = Guid.NewGuid().ToString(); + + await Assert.ThrowsAsync(() => _messageService.AddMessage( + _conversationId, true, _sendMessageRequest)); + } + + [Fact] + public async Task AddMessage_ProfileNotFound() + { + _profileServiceMock.Setup(m => m.ProfileExists(_senderUsername)) + .ReturnsAsync(false); + + await Assert.ThrowsAsync(() => _messageService.AddMessage( + _conversationId, true, _sendMessageRequest)); + } + + [Fact] + public async Task AddMessage_ConversationDoesNotExist() + { + _profileServiceMock.Setup(m => m.ProfileExists(_senderUsername)) + .ReturnsAsync(true); + + _messageStoreMock.Setup(m => m.ConversationPartitionExists(_conversationId)) + .ReturnsAsync(false); + + await Assert.ThrowsAsync(() => _messageService.AddMessage( + _conversationId, false, _sendMessageRequest)); + } + + [Fact] + public async Task GetMessages_Success() + { + _messageStoreMock.Setup(m => m.ConversationPartitionExists(_conversationId)) + .ReturnsAsync(true); + + List messages = new List { + new Message + { + MessageId = Guid.NewGuid().ToString(), + UnixTime = _unixTimeNow, + SenderUsername = _senderUsername, + Text = "Hello" + }, + new Message + { + MessageId = Guid.NewGuid().ToString(), + UnixTime = _unixTimeNow, + SenderUsername = _senderUsername, + Text = "Good Bye" + } + }; + + string nextContinuationToken = Guid.NewGuid().ToString(); + + _messageStoreMock.Setup(m => m.GetMessages(_conversationId, 10, OrderBy.DESC, null, 0)) + .ReturnsAsync((messages, nextContinuationToken)); + + GetMessagesServiceResult expectedGetMessagesServiceResult = new GetMessagesServiceResult + { + Messages = messages, + NextContinuationToken = nextContinuationToken + }; + + GetMessagesServiceResult receivedGetMessagesServiceResult = await _messageService.GetMessages( + _conversationId, 10, OrderBy.DESC, null, 0); + + Assert.Equal(expectedGetMessagesServiceResult.Messages, receivedGetMessagesServiceResult.Messages); + Assert.Equal(expectedGetMessagesServiceResult.NextContinuationToken, receivedGetMessagesServiceResult.NextContinuationToken); + } + + [Theory] + [InlineData(null, 1, 1)] + [InlineData("", 1, 1)] + [InlineData(" ", 1, 1)] + [InlineData("conversationId", 0, 1)] + [InlineData("conversationId", -1, 1)] + [InlineData("conversationId", 1, -1)] + public async Task GetMessages_InvalidArguments(string conversationId, int limit, long lastSeenConversationTime) + { + await Assert.ThrowsAsync(() => _messageService.GetMessages(conversationId, limit, + OrderBy.DESC, null, lastSeenConversationTime)); + } + + [Fact] + public async Task GetMessages_ConversationDoesNotExist() + { + _messageStoreMock.Setup(m => m.ConversationPartitionExists(_conversationId)) + .ReturnsAsync(false); + + await Assert.ThrowsAsync(() => _messageService.GetMessages( + _conversationId, 1, OrderBy.DESC, null, 0)); + } +} \ No newline at end of file diff --git a/ChatService.Web.Tests/Services/UserConversationServiceTests.cs b/ChatService.Web.Tests/Services/UserConversationServiceTests.cs index 4a1aa41..77cae17 100644 --- a/ChatService.Web.Tests/Services/UserConversationServiceTests.cs +++ b/ChatService.Web.Tests/Services/UserConversationServiceTests.cs @@ -3,6 +3,7 @@ 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; @@ -65,9 +66,9 @@ public async Task CreateConversation_Success() response.CreatedUnixTime = _unixTimeNow; - StartConversationResponse expected = new StartConversationResponse + StartConversationServiceResult expected = new StartConversationServiceResult { - ConversationId = GenerateConversationId( + ConversationId = ConversationIdUtilities.GenerateConversationId( _participants.ElementAt(0), _participants.ElementAt(1)), CreatedUnixTime = _unixTimeNow }; @@ -170,13 +171,13 @@ public async Task GetUserConversations_Success() new UserConversation { Username = username1, - ConversationId = GenerateConversationId(username1, username2), + ConversationId = ConversationIdUtilities.GenerateConversationId(username1, username2), LastModifiedTime = _unixTimeNow }, new UserConversation { Username = username1, - ConversationId = GenerateConversationId(username1, username3), + ConversationId = ConversationIdUtilities.GenerateConversationId(username1, username3), LastModifiedTime = _unixTimeNow } }; @@ -197,13 +198,13 @@ public async Task GetUserConversations_Success() { new Conversation { - ConversationId = GenerateConversationId(username1, username2), + ConversationId = ConversationIdUtilities.GenerateConversationId(username1, username2), LastModifiedUnixTime = _unixTimeNow, Recipient = profile2 }, new Conversation { - ConversationId = GenerateConversationId(username1, username3), + ConversationId = ConversationIdUtilities.GenerateConversationId(username1, username3), LastModifiedUnixTime = _unixTimeNow, Recipient = profile3 } @@ -248,15 +249,6 @@ await Assert.ThrowsAsync( () => _participants.ElementAt(0), 10, OrderBy.DESC, null, 0)); } - private string GenerateConversationId(string username1, string username2) - { - if (username1.CompareTo(username2) < 0) - { - return username1 + "_" + username2; - } - return username2 + "_" + username1; - } - public static IEnumerable GenerateInvalidParticipantsList(){ yield return new object[] { new List {_participants.ElementAt(0), ""} }; diff --git a/ChatService.Web/Controllers/ConversationsController.cs b/ChatService.Web/Controllers/ConversationsController.cs index a875ba9..8dada59 100644 --- a/ChatService.Web/Controllers/ConversationsController.cs +++ b/ChatService.Web/Controllers/ConversationsController.cs @@ -57,12 +57,16 @@ public async Task> GetUserConversatio [HttpPost] public async Task> StartConversation(StartConversationRequest request) { - //TODO: add a start conversation service result - StartConversationResponse response; - try { - response = await _userConversationService.CreateConversation(request); + StartConversationServiceResult result = await _userConversationService.CreateConversation(request); + StartConversationResponse response = new StartConversationResponse + { + ConversationId = result.ConversationId, + CreatedUnixTime = result.CreatedUnixTime + }; + return CreatedAtAction(nameof(GetUserConversations), + new { username = request.FirstMessage.SenderUsername }, response); } catch (ArgumentException e) { @@ -76,21 +80,29 @@ public async Task> StartConversation(Sta { return Conflict(e.Message); } - - return CreatedAtAction(nameof(GetUserConversations), - new { username = request.FirstMessage.SenderUsername }, response); } [HttpGet("{conversationId}/messages")] public async Task> GetMessages(string conversationId, int limit = 10, OrderBy orderBy = OrderBy.DESC, string? continuationToken = null, long lastSeenConversationTime = 0) { - GetMessagesServiceResult result; - try { - result = await _messageService.GetMessages( + GetMessagesServiceResult result = await _messageService.GetMessages( conversationId, limit, orderBy, continuationToken, lastSeenConversationTime); + + string nextUri = $"/api/conversations/{conversationId}/messages" + + $"&limit={limit}" + + $"&continuationToken={result.NextContinuationToken}" + + $"&lastSeenConversationTime={lastSeenConversationTime}"; + + GetMessagesResponse response = new GetMessagesResponse + { + Messages = result.Messages, + NextUri = nextUri + }; + + return Ok(response); } catch (ArgumentException e) { @@ -100,29 +112,16 @@ public async Task> GetMessages(string conversa { return NotFound(e.Message); } - - string nextUri = $"/api/conversations/{conversationId}/messages" + - $"&limit={limit}" + - $"&continuationToken={result.NextContinuationToken}" + - $"&lastSeenConversationTime={lastSeenConversationTime}"; - - GetMessagesResponse response = new GetMessagesResponse - { - Messages = result.Messages, - NextUri = nextUri - }; - - return Ok(response); } [HttpPost("{conversationId}/messages")] public async Task> PostMessage(string conversationId, SendMessageRequest request) { - SendMessageResponse response; - try { - response = await _messageService.AddMessage(conversationId, false, request); + SendMessageResponse response = await _messageService.AddMessage(conversationId, false, request); + + return CreatedAtAction(nameof(GetMessages), new { conversationId = conversationId}, response); } catch (ArgumentException e) { @@ -140,7 +139,5 @@ public async Task> PostMessage(string conversa { return Conflict(e.Message); } - - return CreatedAtAction(nameof(GetMessages), new { conversationId = conversationId}, response); } } \ No newline at end of file diff --git a/ChatService.Web/Dtos/StartConversationServiceResult.cs b/ChatService.Web/Dtos/StartConversationServiceResult.cs new file mode 100644 index 0000000..bd2f1bc --- /dev/null +++ b/ChatService.Web/Dtos/StartConversationServiceResult.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace ChatService.Web.Dtos; + +public record StartConversationServiceResult +{ + [Required] public string ConversationId { get; set; } + [Required] public long CreatedUnixTime { get; set; } +} \ No newline at end of file diff --git a/ChatService.Web/Program.cs b/ChatService.Web/Program.cs index a6a16a3..69178cb 100644 --- a/ChatService.Web/Program.cs +++ b/ChatService.Web/Program.cs @@ -7,11 +7,9 @@ var builder = WebApplication.CreateBuilder(args); -// Add Configuration builder.Services.Configure(builder.Configuration.GetSection("Cosmos")); builder.Services.Configure(builder.Configuration.GetSection("BlobStorage")); -// Add services to the container. builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -34,13 +32,11 @@ builder.Services.AddSingleton(); builder.Services.AddControllers(); -// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); var app = builder.Build(); -// Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.UseSwagger(); diff --git a/ChatService.Web/Services/IImageService.cs b/ChatService.Web/Services/IImageService.cs index c4210e5..36ca1a7 100644 --- a/ChatService.Web/Services/IImageService.cs +++ b/ChatService.Web/Services/IImageService.cs @@ -7,4 +7,6 @@ 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/IUserConversationService.cs b/ChatService.Web/Services/IUserConversationService.cs index ea3f0c7..caff149 100644 --- a/ChatService.Web/Services/IUserConversationService.cs +++ b/ChatService.Web/Services/IUserConversationService.cs @@ -5,7 +5,7 @@ namespace ChatService.Web.Services; public interface IUserConversationService { - Task CreateConversation(StartConversationRequest request); + Task CreateConversation(StartConversationRequest request); Task GetUserConversations( string username, int limit, OrderBy orderBy, string? continuationToken, long lastSeenConversationTime); } \ No newline at end of file diff --git a/ChatService.Web/Services/ImageService.cs b/ChatService.Web/Services/ImageService.cs index f8b5082..9b0a053 100644 --- a/ChatService.Web/Services/ImageService.cs +++ b/ChatService.Web/Services/ImageService.cs @@ -37,6 +37,16 @@ public async Task DownloadImage(string imageId) return new FileContentResult(image.Content.ToArray(), image.ContentType); } + public async Task DeleteImage(string imageId) + { + await _imageStore.DeleteImage(imageId); + } + + public async Task ImageExists(string imageId) + { + return await _imageStore.ImageExists(imageId); + } + private void ValidateImage(Image image) { string contentType = image.ContentType.ToLower(); diff --git a/ChatService.Web/Services/MessageService.cs b/ChatService.Web/Services/MessageService.cs index 24c0501..3b189e5 100644 --- a/ChatService.Web/Services/MessageService.cs +++ b/ChatService.Web/Services/MessageService.cs @@ -19,44 +19,22 @@ public MessageService(IMessageStore messageStore, IProfileService profileService public async Task AddMessage(string conversationId, bool isFirstMessage, SendMessageRequest request) { - if (request == null || - string.IsNullOrWhiteSpace(request.MessageId) || - string.IsNullOrWhiteSpace(request.SenderUsername) || - string.IsNullOrWhiteSpace(request.Text) - ) - { - throw new ArgumentException($"Invalid SendMessageRequest {request}."); - } - - if (string.IsNullOrWhiteSpace(conversationId)) - { - throw new ArgumentException($"Invalid conversationId {conversationId}."); - } - - //check if converstionId contains sender username to know if they are allowed to send message here - if (!conversationId.Contains(request.SenderUsername)) - { - //TODO: 403 error code in controller - throw new UserNotParticipantException( - $"User {request.SenderUsername} is not a participant of conversation {conversationId}."); - } + ValidateSendMessageRequest(request); + ValidateConversationId(conversationId); + AuthorizeSender(conversationId, request.SenderUsername); - //check if the sender's profile exists if (!await _profileService.ProfileExists(request.SenderUsername)) { throw new ProfileNotFoundException( $"A profile with the username {request.SenderUsername} was not found."); } - - //if this is NOT the first message, check if the conversation already exists + if (!isFirstMessage && !await _messageStore.ConversationPartitionExists(conversationId)) { throw new ConversationDoesNotExistException( $"A conversation partition with the conversationId {conversationId} does not exist."); } - //if it IS the first message, then its ok if the conversation does not exist as the partition will be created - //add the message to the conversation partition long unixTimeNow = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); Message message = new Message @@ -68,7 +46,6 @@ public async Task AddMessage(string conversationId, bool is }; await _messageStore.AddMessage(conversationId, message); - //////////////////////////////////// return new SendMessageResponse { @@ -84,28 +61,16 @@ public async Task AddFirstMessage(string conversationId, Se public async Task GetMessages(string conversationId, int limit, OrderBy orderBy, string? continuationToken, long lastSeenConversationTime) { - if (string.IsNullOrWhiteSpace(conversationId)) - { - throw new ArgumentException($"Invalid conversationId {conversationId}."); - } - - if (limit <= 0) - { - throw new ArgumentException($"Invalid limit {limit}. Limit must be greater or equal to 1."); - } - - if (lastSeenConversationTime < 0) - { - throw new ArgumentException( - $"Invalid lastSeenConversationTime {lastSeenConversationTime}. lastSeenConversationTime must be greater or equal to 0."); - } + ValidateConversationId(conversationId); + ValidateLimit(limit); + ValidateLastSeenConversationTime(lastSeenConversationTime); if (!await _messageStore.ConversationPartitionExists(conversationId)) { throw new ConversationDoesNotExistException( $"A conversation partition with the conversationId {conversationId} does not exist."); } - + var result = await _messageStore.GetMessages( conversationId, limit, orderBy, continuationToken, lastSeenConversationTime); @@ -115,4 +80,51 @@ public async Task GetMessages(string conversationId, i NextContinuationToken = result.NextContinuationToken }; } + + private void ValidateSendMessageRequest(SendMessageRequest request) + { + if (request == null || + string.IsNullOrWhiteSpace(request.MessageId) || + string.IsNullOrWhiteSpace(request.SenderUsername) || + string.IsNullOrWhiteSpace(request.Text) + ) + { + throw new ArgumentException($"Invalid SendMessageRequest {request}."); + } + } + + private void ValidateConversationId(string conversationId) + { + if (string.IsNullOrWhiteSpace(conversationId)) + { + throw new ArgumentException($"Invalid conversationId {conversationId}."); + } + } + + private void ValidateLimit(int limit) + { + if (limit <= 0) + { + throw new ArgumentException($"Invalid limit {limit}. Limit must be greater or equal to 1."); + } + } + + private void ValidateLastSeenConversationTime(long lastSeenConversationTime) + { + if (lastSeenConversationTime < 0) + { + throw new ArgumentException($"Invalid lastSeenConversationTime {lastSeenConversationTime}. " + + $"LastSeenConversationTime must be greater or equal to 0."); + } + } + + private void AuthorizeSender(string conversationId, string senderUsername) + { + string[] usernames = conversationId.Split('_'); + if (!usernames[0].Equals(senderUsername) && !usernames[1].Equals(senderUsername)) + { + throw new UserNotParticipantException( + $"User {senderUsername} is not a participant of conversation {conversationId}."); + } + } } \ No newline at end of file diff --git a/ChatService.Web/Services/ProfileService.cs b/ChatService.Web/Services/ProfileService.cs index 04dbaf3..3491f40 100644 --- a/ChatService.Web/Services/ProfileService.cs +++ b/ChatService.Web/Services/ProfileService.cs @@ -7,46 +7,29 @@ namespace ChatService.Web.Services; public class ProfileService : IProfileService { private readonly IProfileStore _profileStore; - private readonly IImageStore _imageStore; - - public ProfileService(IProfileStore profileStore, IImageStore imageStore) + private readonly IImageService _imageService; + + public ProfileService(IProfileStore profileStore, IImageService imageService) { _profileStore = profileStore; - _imageStore = imageStore; + _imageService = imageService; } public async Task GetProfile(string username) { - //MAKE SURE THIS CHECK IS CORRECT - if (string.IsNullOrWhiteSpace(username)) - { - throw new ArgumentException($"Invalid username {username}"); - } + ValidateUsername(username); + return await _profileStore.GetProfile(username); } public async Task AddProfile(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)); - } + ValidateProfile(profile); - if (profile.Username.Contains('_')) - { - throw new InvalidUsernameException($"Username {profile.Username} is invalid. Usernames cannot have an underscore."); - } - - bool imageExists = await _imageStore.ImageExists(profile.ProfilePictureId); + bool imageExists = await _imageService.ImageExists(profile.ProfilePictureId); if (!imageExists) { - throw new ImageNotFoundException( - $"Profile picture with ID {profile.ProfilePictureId} was not found."); + throw new ImageNotFoundException($"Profile picture with ID {profile.ProfilePictureId} was not found."); } await _profileStore.AddProfile(profile); @@ -69,7 +52,32 @@ public async Task DeleteProfile(string username) { throw new ArgumentException($"Profile with username {username} doesn't exist."); } - await _imageStore.DeleteImage(profile.ProfilePictureId); + await _imageService.DeleteImage(profile.ProfilePictureId); await _profileStore.DeleteProfile(username); } + + private void ValidateUsername(string username) + { + if (string.IsNullOrWhiteSpace(username)) + { + throw new ArgumentException($"Invalid username {username}"); + } + } + + private void ValidateProfile(Profile profile) + { + if (profile == null || + string.IsNullOrWhiteSpace(profile.Username) || + string.IsNullOrWhiteSpace(profile.FirstName) || + string.IsNullOrWhiteSpace(profile.LastName) || + string.IsNullOrWhiteSpace(profile.ProfilePictureId) + ) + { + throw new ArgumentException($"Invalid profile {profile}", nameof(profile)); + } + if (profile.Username.Contains('_')) + { + throw new InvalidUsernameException($"Username {profile.Username} is invalid. Usernames cannot have an underscore."); + } + } } \ No newline at end of file diff --git a/ChatService.Web/Services/UserConversationService.cs b/ChatService.Web/Services/UserConversationService.cs index d61fe7c..8d773a5 100644 --- a/ChatService.Web/Services/UserConversationService.cs +++ b/ChatService.Web/Services/UserConversationService.cs @@ -2,6 +2,7 @@ using ChatService.Web.Enums; using ChatService.Web.Exceptions; using ChatService.Web.Storage; +using ChatService.Web.Utilities; namespace ChatService.Web.Services; @@ -12,63 +13,35 @@ public class UserConversationService : IUserConversationService private readonly IUserConversationStore _userConversationStore; private readonly IProfileService _profileService; - public UserConversationService(IMessageService messageService, IUserConversationStore userConversationStore, IProfileService profileService) + public UserConversationService(IMessageService messageService, IUserConversationStore userConversationStore, + IProfileService profileService) { _messageService = messageService; _userConversationStore = userConversationStore; _profileService = profileService; } - public async Task CreateConversation(StartConversationRequest request) + public async Task CreateConversation(StartConversationRequest request) { - if (request == null) - { - throw new ArgumentException($"StartConversationRequest is null."); - } - - if (request.Participants.Count < 2 || - string.IsNullOrWhiteSpace(request.Participants.ElementAt(0)) || - string.IsNullOrWhiteSpace(request.Participants.ElementAt(1)) || - request.Participants.ElementAt(0).Equals(request.Participants.ElementAt(1))) - { - throw new ArgumentException( - $"Invalid participants list ${request.Participants}. There must be 2 unique participant usernames"); - } - - if (string.IsNullOrWhiteSpace(request.FirstMessage.MessageId) || - string.IsNullOrWhiteSpace(request.FirstMessage.SenderUsername) || - string.IsNullOrWhiteSpace(request.FirstMessage.Text)) - { - throw new ArgumentException($"Invalid FirstMessage {request.FirstMessage}."); - } + ValidateStartConversationRequest(request); string username1 = request.Participants.ElementAt(0); string username2 = request.Participants.ElementAt(1); - + if (!await _profileService.ProfileExists(username1)) { throw new ProfileNotFoundException($"A profile with the username {username1} was not found."); } + if (!await _profileService.ProfileExists(username2)) { throw new ProfileNotFoundException($"A profile with the username {username2} was not found."); } - string conversationId; - - if (username1.CompareTo(username2) < 0) - { - conversationId = username1 + "_" + username2; - } - else - { - conversationId = username2 + "_" + username1; - } + string conversationId = ConversationIdUtilities.GenerateConversationId(username1, username2); long unixTimeNow = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - - //TODO: - ////////////// MOVED TO message servicve -- make sure all gucci + SendMessageRequest sendMessageRequest = new SendMessageRequest { MessageId = request.FirstMessage.MessageId, @@ -76,8 +49,7 @@ public async Task CreateConversation(StartConversatio Text = request.FirstMessage.Text }; await _messageService.AddFirstMessage(conversationId, sendMessageRequest); - ////////////////////////////////////////////////////////// - + UserConversation userConversation1 = new UserConversation { Username = username1, @@ -85,7 +57,7 @@ public async Task CreateConversation(StartConversatio LastModifiedTime = unixTimeNow }; await _userConversationStore.CreateUserConversation(userConversation1); - + UserConversation userConversation2 = new UserConversation { Username = username2, @@ -94,7 +66,7 @@ public async Task CreateConversation(StartConversatio }; await _userConversationStore.CreateUserConversation(userConversation2); - return new StartConversationResponse + return new StartConversationServiceResult { ConversationId = conversationId, CreatedUnixTime = unixTimeNow @@ -124,12 +96,12 @@ public async Task GetUserConversations( { throw new UserNotFoundException($"User {username} was not found."); } - + var result = await _userConversationStore.GetUserConversations( username, limit, orderBy, continuationToken, lastSeenConversationTime); List conversations = await UserConversationsToConversations(result.UserConversations); - + return new GetUserConversationsServiceResult { Conversations = conversations, @@ -140,7 +112,7 @@ public async Task GetUserConversations( private async Task> UserConversationsToConversations(List userConversations) { List conversations = new(); - + foreach (UserConversation userConversation in userConversations) { string[] usernames = userConversation.ConversationId.Split('_'); @@ -163,10 +135,34 @@ private async Task> UserConversationsToConversations(ListOn On On - <SessionState ContinuousTestingMode="0" Name="ImagesControllerTests" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <TestAncestor> - <TestId>xUnit::270C3052-C9D2-4EF9-9683-D5E4A4E69733::net6.0::ChatService.Web.Tests.Controllers.ImagesControllerTests</TestId> - </TestAncestor> + <SessionState ContinuousTestingMode="0" IsActive="True" Name="ImagesControllerTests" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Solution /> </SessionState> - <SessionState ContinuousTestingMode="0" IsActive="True" Name="ProfileControllerTests" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <SessionState ContinuousTestingMode="0" Name="ProfileControllerTests" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> </SessionState> From 88630dd69450a133f82eb2feba6ac6aa340514d7 Mon Sep 17 00:00:00 2001 From: nadimakk Date: Fri, 31 Mar 2023 21:11:31 +0300 Subject: [PATCH 40/45] Add ChatService.sln.DotSettings.user to .gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index add57be..26958af 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ bin/ obj/ /packages/ riderModule.iml -/_ReSharper.Caches/ \ No newline at end of file +/_ReSharper.Caches/ +ChatService.sln.DotSettings.user \ No newline at end of file From 5f6a69f8adc27d85a0ba366141d00e97d724d060 Mon Sep 17 00:00:00 2001 From: nadimakk Date: Fri, 31 Mar 2023 23:12:49 +0300 Subject: [PATCH 41/45] Add logs --- .../ChatService.Web.IntegrationTests.csproj | 1 + .../ChatService.Web.Tests.csproj | 1 + .../Controllers/ProfilesControllerTests.cs | 2 +- .../Services/ProfileServiceTests.cs | 2 +- ChatService.Web/ChatService.Web.csproj | 1 + .../Controllers/ConversationsController.cs | 221 +++++++++++------- .../Controllers/ImagesController.cs | 33 ++- .../Controllers/ProfilesController.cs | 52 +++-- ChatService.Web/Program.cs | 2 + ChatService.Web/Services/ProfileService.cs | 12 +- ChatService.Web/appsettings.json | 5 + 11 files changed, 211 insertions(+), 121 deletions(-) diff --git a/ChatService.Web.IntegrationTests/ChatService.Web.IntegrationTests.csproj b/ChatService.Web.IntegrationTests/ChatService.Web.IntegrationTests.csproj index 6b8e912..3f79c19 100644 --- a/ChatService.Web.IntegrationTests/ChatService.Web.IntegrationTests.csproj +++ b/ChatService.Web.IntegrationTests/ChatService.Web.IntegrationTests.csproj @@ -10,6 +10,7 @@ + diff --git a/ChatService.Web.Tests/ChatService.Web.Tests.csproj b/ChatService.Web.Tests/ChatService.Web.Tests.csproj index 1aa9611..d2ff97f 100644 --- a/ChatService.Web.Tests/ChatService.Web.Tests.csproj +++ b/ChatService.Web.Tests/ChatService.Web.Tests.csproj @@ -10,6 +10,7 @@ + diff --git a/ChatService.Web.Tests/Controllers/ProfilesControllerTests.cs b/ChatService.Web.Tests/Controllers/ProfilesControllerTests.cs index 44bf176..33f78a1 100644 --- a/ChatService.Web.Tests/Controllers/ProfilesControllerTests.cs +++ b/ChatService.Web.Tests/Controllers/ProfilesControllerTests.cs @@ -53,7 +53,7 @@ public async Task GetProfile_Success() public async Task GetProfile_ProfileNotFound() { _profileServiceMock.Setup(m => m.GetProfile(_profile.Username)) - .ReturnsAsync((Profile?) null); + .ThrowsAsync(new ProfileNotFoundException($"A profile with the username {_profile.Username} was not found.")); var response = await _httpClient.GetAsync($"api/Profiles/{_profile.Username}"); diff --git a/ChatService.Web.Tests/Services/ProfileServiceTests.cs b/ChatService.Web.Tests/Services/ProfileServiceTests.cs index e54ffc6..e7d589a 100644 --- a/ChatService.Web.Tests/Services/ProfileServiceTests.cs +++ b/ChatService.Web.Tests/Services/ProfileServiceTests.cs @@ -149,7 +149,7 @@ public async Task DeleteProfile_Success() [Fact] public async Task DeleteProfile_ProfileNotFound() { - await Assert.ThrowsAsync( + await Assert.ThrowsAsync( async () => await _profileService.DeleteProfile(_profile.Username)); _imageStoreMock.Verify(m => m.DeleteImage(_profile.ProfilePictureId), Times.Never); diff --git a/ChatService.Web/ChatService.Web.csproj b/ChatService.Web/ChatService.Web.csproj index 8ff205e..339ae2e 100644 --- a/ChatService.Web/ChatService.Web.csproj +++ b/ChatService.Web/ChatService.Web.csproj @@ -8,6 +8,7 @@ + diff --git a/ChatService.Web/Controllers/ConversationsController.cs b/ChatService.Web/Controllers/ConversationsController.cs index 8dada59..b751221 100644 --- a/ChatService.Web/Controllers/ConversationsController.cs +++ b/ChatService.Web/Controllers/ConversationsController.cs @@ -12,73 +12,97 @@ public class ConversationsController : ControllerBase { private readonly IUserConversationService _userConversationService; private readonly IMessageService _messageService; + private readonly ILogger _logger; - public ConversationsController(IUserConversationService userConversationService, IMessageService messageService) + + 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) - { - GetUserConversationsServiceResult result; + int limit = 10, OrderBy orderBy = OrderBy.DESC, string? continuationToken = null, long lastSeenConversationTime = 0){ - try - { - result = await _userConversationService.GetUserConversations( - username, limit, orderBy, continuationToken, lastSeenConversationTime); - } - catch (ArgumentException e) - { - return BadRequest(e.Message); - } - catch (UserNotFoundException e) + using (_logger.BeginScope("{Username}", username)) { - return NotFound(e.Message); - } + try + { + GetUserConversationsServiceResult result = await _userConversationService.GetUserConversations( + username, limit, orderBy, continuationToken, lastSeenConversationTime); + + _logger.LogInformation("Fetched conversations of user {Username}", username); + + string nextUri = "/api/conversations" + + $"?username={username}" + + $"&limit={limit}" + + $"&lastSeenConversationTime={lastSeenConversationTime}" + + $"&continuationToken={result.NextContinuationToken}"; - string nextUri = "/api/conversations" + - $"?username={username}" + - $"&limit={limit}" + - $"&lastSeenConversationTime={lastSeenConversationTime}" + - $"&continuationToken={result.NextContinuationToken}"; + GetUserConversationsResponse response = new GetUserConversationsResponse + { + Conversations = result.Conversations, + NextUri = nextUri + }; - GetUserConversationsResponse response = new GetUserConversationsResponse - { - Conversations = result.Conversations, - NextUri = nextUri - }; - - return Ok(response); + return Ok(response); + } + catch (ArgumentException e) + { + _logger.LogError(e, "Error getting user conversations: {ErrorMessage}", e.Message); + return BadRequest(e.Message); + } + catch (UserNotFoundException e) + { + _logger.LogError(e, "Error getting user conversations: {ErrorMessage}", e.Message); + return NotFound(e.Message); + } + } } [HttpPost] public async Task> StartConversation(StartConversationRequest request) { - try + using (_logger.BeginScope("{SenderUsername}", request.FirstMessage.SenderUsername)) { - StartConversationServiceResult result = await _userConversationService.CreateConversation(request); - StartConversationResponse response = new StartConversationResponse + try { - ConversationId = result.ConversationId, - CreatedUnixTime = result.CreatedUnixTime - }; - return CreatedAtAction(nameof(GetUserConversations), - new { username = request.FirstMessage.SenderUsername }, response); - } - catch (ArgumentException e) - { - return BadRequest(e.Message); - } - catch (ProfileNotFoundException e) - { - return NotFound(e.Message); - } - catch (MessageExistsException e) - { - return Conflict(e.Message); + StartConversationServiceResult result = await _userConversationService.CreateConversation(request); + + _logger.LogInformation( + "Created user conversation with Id {ConversationId} for user {Username}", + result.ConversationId, request.FirstMessage.SenderUsername); + + StartConversationResponse response = new StartConversationResponse + { + ConversationId = result.ConversationId, + CreatedUnixTime = result.CreatedUnixTime + }; + + return CreatedAtAction(nameof(GetUserConversations), + new { username = request.FirstMessage.SenderUsername }, response); + } + catch (ArgumentException e) + { + _logger.LogError(e, "Error creating user conversation: {ErrorMessage}", e.Message); + return BadRequest(e.Message); + } + catch (ProfileNotFoundException e) + { + _logger.LogError(e, "Error creating user conversation: {ErrorMessage}", e.Message); + return NotFound(e.Message); + } + catch (MessageExistsException e) + { + _logger.LogError(e, "Error creating user conversation: {ErrorMessage}", e.Message); + return Conflict(e.Message); + } } } @@ -86,58 +110,79 @@ public async Task> StartConversation(Sta public async Task> GetMessages(string conversationId, int limit = 10, OrderBy orderBy = OrderBy.DESC, string? continuationToken = null, long lastSeenConversationTime = 0) { - try + using (_logger.BeginScope("{ConversationId}", conversationId)) { - GetMessagesServiceResult result = await _messageService.GetMessages( - conversationId, limit, orderBy, continuationToken, lastSeenConversationTime); + try + { + GetMessagesServiceResult result = await _messageService.GetMessages( + conversationId, limit, orderBy, continuationToken, lastSeenConversationTime); - string nextUri = $"/api/conversations/{conversationId}/messages" + - $"&limit={limit}" + - $"&continuationToken={result.NextContinuationToken}" + - $"&lastSeenConversationTime={lastSeenConversationTime}"; + _logger.LogInformation("Fetched messages from conversation {ConversationId}", conversationId); + + string nextUri = $"/api/conversations/{conversationId}/messages" + + $"&limit={limit}" + + $"&continuationToken={result.NextContinuationToken}" + + $"&lastSeenConversationTime={lastSeenConversationTime}"; - GetMessagesResponse response = new GetMessagesResponse - { - Messages = result.Messages, - NextUri = nextUri - }; + GetMessagesResponse response = new GetMessagesResponse + { + Messages = result.Messages, + NextUri = nextUri + }; - return Ok(response); - } - catch (ArgumentException e) - { - return BadRequest(e.Message); - } - catch (ConversationDoesNotExistException e) - { - return NotFound(e.Message); + return Ok(response); + } + catch (ArgumentException e) + { + _logger.LogError(e, "Error getting messages: {ErrorMessage}", e.Message); + return BadRequest(e.Message); + } + catch (ConversationDoesNotExistException e) + { + _logger.LogError(e, "Error getting messages: {ErrorMessage}", e.Message); + return NotFound(e.Message); + } } } [HttpPost("{conversationId}/messages")] public async Task> PostMessage(string conversationId, SendMessageRequest request) { - try + using (_logger.BeginScope(new Dictionary + { + {"ConversationId", conversationId}, + {"SenderUsername", request.SenderUsername} + })) { - SendMessageResponse response = await _messageService.AddMessage(conversationId, false, request); + try + { + SendMessageResponse response = await _messageService.AddMessage(conversationId, false, request); - return CreatedAtAction(nameof(GetMessages), new { conversationId = conversationId}, response); - } - catch (ArgumentException e) - { - return BadRequest(e.Message); - } - catch (UserNotParticipantException e) - { - return new ObjectResult(e.Message) { StatusCode = 403 }; - } - catch (Exception e) when (e is ProfileNotFoundException || e is ConversationDoesNotExistException) - { - return NotFound(e.Message); - } - catch (MessageExistsException e) - { - return Conflict(e.Message); + _logger.LogInformation("Adding message {MessageId} to conversation {ConversationId} by sender {SenderUsername}", + request.MessageId, conversationId, request.SenderUsername); + + return CreatedAtAction(nameof(GetMessages), new { conversationId = conversationId}, response); + } + catch (ArgumentException e) + { + _logger.LogError(e, "Error adding message: {ErrorMessage}", e.Message); + return BadRequest(e.Message); + } + catch (UserNotParticipantException e) + { + _logger.LogError(e, "Error adding message: {ErrorMessage}", e.Message); + return new ObjectResult(e.Message) { StatusCode = 403 }; + } + catch (Exception e) when (e is ProfileNotFoundException || e is ConversationDoesNotExistException) + { + _logger.LogError(e, "Error adding message: {ErrorMessage}", e.Message); + return NotFound(e.Message); + } + catch (MessageExistsException e) + { + _logger.LogError(e, "Error adding message: {ErrorMessage}", e.Message); + return Conflict(e.Message); + } } } } \ No newline at end of file diff --git a/ChatService.Web/Controllers/ImagesController.cs b/ChatService.Web/Controllers/ImagesController.cs index c5345a6..3a515b1 100644 --- a/ChatService.Web/Controllers/ImagesController.cs +++ b/ChatService.Web/Controllers/ImagesController.cs @@ -10,10 +10,12 @@ namespace ChatService.Web.Controllers; public class ImagesController : ControllerBase { private readonly IImageService _imageService; + private readonly ILogger _logger; - public ImagesController(IImageService imageService) + public ImagesController(IImageService imageService, ILogger logger) { _imageService = imageService; + _logger = logger; } [HttpPost] @@ -26,11 +28,13 @@ public async Task> UploadImage([FromForm] Uplo try { UploadImageServiceResult result = await _imageService.UploadImage(image); + _logger.LogInformation("Uploaded image with id {id}.", result.ImageId); return CreatedAtAction(nameof(DownloadImage), new { imageId = result.ImageId }, new UploadImageResponse(result.ImageId)); } catch (InvalidImageTypeException e) { + _logger.LogError(e, "Error uploading image: {ErrorMessage}", e.Message); return BadRequest(e.Message); } } @@ -38,17 +42,24 @@ public async Task> UploadImage([FromForm] Uplo [HttpGet("{imageId}")] public async Task DownloadImage(string imageId) { - try - { - return await _imageService.DownloadImage(imageId); - } - catch (ArgumentException e) - { - return BadRequest(e.Message); - } - catch (ImageNotFoundException e) + using (_logger.BeginScope("{ImageId}", imageId)) { - return NotFound(e.Message); + try + { + var result = await _imageService.DownloadImage(imageId); + _logger.LogInformation("Downloaded image with id {id}.", imageId); + return result; + } + catch (ArgumentException e) + { + _logger.LogError(e, "Error downloading image: {ErrorMessage}", e.Message); + return BadRequest(e.Message); + } + catch (ImageNotFoundException e) + { + _logger.LogError(e, "Error downloading image: {ErrorMessage}", e.Message); + return NotFound(e.Message); + } } } } \ No newline at end of file diff --git a/ChatService.Web/Controllers/ProfilesController.cs b/ChatService.Web/Controllers/ProfilesController.cs index 56880ea..56e7be8 100644 --- a/ChatService.Web/Controllers/ProfilesController.cs +++ b/ChatService.Web/Controllers/ProfilesController.cs @@ -10,39 +10,55 @@ namespace ChatService.Web.Controllers; public class ProfilesController : ControllerBase { private readonly IProfileService _profileService; - - public ProfilesController(IProfileService profileService) + private readonly ILogger _logger; + + public ProfilesController(IProfileService profileService, ILogger logger) { _profileService = profileService; + _logger = logger; + } [HttpGet("{username}")] public async Task> GetProfile(string username) { - var profile = await _profileService.GetProfile(username); - if (profile == null) + using (_logger.BeginScope("{Username}", username)) { - return NotFound($"A profile with the username {username} was not found."); + try + { + var profile = await _profileService.GetProfile(username); + _logger.LogInformation("Profile of {Username} fetched.", username); + return Ok(profile); + } + catch (ProfileNotFoundException e) + { + _logger.LogError(e, "Error finding profile: {ErrorMessage}", e.Message); + return NotFound(e.Message); + } } - - return Ok(profile); } [HttpPost] public async Task> PostProfile(Profile profile) { - try - { - await _profileService.AddProfile(profile); - return CreatedAtAction(nameof(GetProfile), new { username = profile.Username }, profile); - } - catch (Exception e) when (e is ArgumentException || e is ImageNotFoundException || e is InvalidUsernameException) - { - return BadRequest(e.Message); - } - catch (UsernameTakenException e) + using (_logger.BeginScope("{Profile}", profile)) { - return Conflict(e.Message); + try + { + await _profileService.AddProfile(profile); + _logger.LogInformation("Created Profile for user {ProfileUsername}.", profile.Username); + return CreatedAtAction(nameof(GetProfile), new { username = profile.Username }, profile); + } + catch (Exception e) when (e is ArgumentException || e is ImageNotFoundException || e is InvalidUsernameException) + { + _logger.LogError(e, "Error posting profile: {ErrorMessage}", e.Message); + return BadRequest(e.Message); + } + catch (UsernameTakenException e) + { + _logger.LogError(e, "Error posting profile: {ErrorMessage}", e.Message); + return Conflict(e.Message); + } } } } \ No newline at end of file diff --git a/ChatService.Web/Program.cs b/ChatService.Web/Program.cs index 69178cb..1aa2e85 100644 --- a/ChatService.Web/Program.cs +++ b/ChatService.Web/Program.cs @@ -35,6 +35,8 @@ builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); +builder.Services.AddApplicationInsightsTelemetry(); + var app = builder.Build(); if (app.Environment.IsDevelopment()) diff --git a/ChatService.Web/Services/ProfileService.cs b/ChatService.Web/Services/ProfileService.cs index 3491f40..5b4638f 100644 --- a/ChatService.Web/Services/ProfileService.cs +++ b/ChatService.Web/Services/ProfileService.cs @@ -19,7 +19,15 @@ public ProfileService(IProfileStore profileStore, IImageService imageService) { ValidateUsername(username); - return await _profileStore.GetProfile(username); + var profile = await _profileStore.GetProfile(username); + + if (profile == null) + { + throw new ProfileNotFoundException( + $"A profile with the username {username} was not found."); + } + + return profile; } public async Task AddProfile(Profile profile) @@ -50,7 +58,7 @@ public async Task DeleteProfile(string username) Profile? profile = await GetProfile(username); if (profile == null) { - throw new ArgumentException($"Profile with username {username} doesn't exist."); + throw new ProfileNotFoundException($"Profile with username {username} does not exist."); } await _imageService.DeleteImage(profile.ProfilePictureId); await _profileStore.DeleteProfile(username); diff --git a/ChatService.Web/appsettings.json b/ChatService.Web/appsettings.json index 16ab155..93523b1 100644 --- a/ChatService.Web/appsettings.json +++ b/ChatService.Web/appsettings.json @@ -3,6 +3,11 @@ "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" + }, + "ApplicationInsights": { + "LogLevel": { + "Default": "Information" + } } }, From d5117a9279dd1164eebacfb07598e9b76c904e1a Mon Sep 17 00:00:00 2001 From: nadimakk Date: Sat, 1 Apr 2023 17:00:09 +0300 Subject: [PATCH 42/45] change log level --- ChatService.Web/appsettings.json | 4 ++-- ChatService.sln.DotSettings.user | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/ChatService.Web/appsettings.json b/ChatService.Web/appsettings.json index 93523b1..d6a643d 100644 --- a/ChatService.Web/appsettings.json +++ b/ChatService.Web/appsettings.json @@ -1,12 +1,12 @@ { "Logging": { "LogLevel": { - "Default": "Information", + "Default": "Trace", "Microsoft.AspNetCore": "Warning" }, "ApplicationInsights": { "LogLevel": { - "Default": "Information" + "Default": "Trace" } } }, diff --git a/ChatService.sln.DotSettings.user b/ChatService.sln.DotSettings.user index 3ff31e8..81131c0 100644 --- a/ChatService.sln.DotSettings.user +++ b/ChatService.sln.DotSettings.user @@ -3,13 +3,14 @@ On On On - <SessionState ContinuousTestingMode="0" IsActive="True" Name="ImagesControllerTests" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + C:\Users\nadim\AppData\Local\JetBrains\Rider2022.3\resharper-host\temp\Rider\vAny\CoverageData\_ChatService.34854384\Snapshot\snapshot.utdcvr + <SessionState ContinuousTestingMode="0" Name="ImagesControllerTests" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> </SessionState> - <SessionState ContinuousTestingMode="0" Name="ProfileControllerTests" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <SessionState ContinuousTestingMode="0" IsActive="True" Name="ProfileControllerTests" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> </SessionState> From f1c9a63183a3c9d03f2a7c8b1b7eef934bdb062d Mon Sep 17 00:00:00 2001 From: Ali Date: Sat, 1 Apr 2023 18:40:04 +0300 Subject: [PATCH 43/45] Bug fixes --- .../Controllers/ConversationsController.cs | 39 ++++++++++++------- .../Dtos/GetMessagesServiceResult.cs | 2 +- .../Dtos/GetUserConversationsServiceResult.cs | 2 +- 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/ChatService.Web/Controllers/ConversationsController.cs b/ChatService.Web/Controllers/ConversationsController.cs index b751221..ca0b55d 100644 --- a/ChatService.Web/Controllers/ConversationsController.cs +++ b/ChatService.Web/Controllers/ConversationsController.cs @@ -38,12 +38,16 @@ public async Task> GetUserConversatio username, limit, orderBy, continuationToken, lastSeenConversationTime); _logger.LogInformation("Fetched conversations of user {Username}", username); - - string nextUri = "/api/conversations" + - $"?username={username}" + - $"&limit={limit}" + - $"&lastSeenConversationTime={lastSeenConversationTime}" + - $"&continuationToken={result.NextContinuationToken}"; + + string nextUri = ""; + if (result.NextContinuationToken != null) + { + nextUri = "/api/conversations" + + $"?username={username}" + + $"&limit={limit}" + + $"&lastSeenConversationTime={lastSeenConversationTime}" + + $"&continuationToken={result.NextContinuationToken}"; + } GetUserConversationsResponse response = new GetUserConversationsResponse { @@ -98,7 +102,12 @@ public async Task> StartConversation(Sta _logger.LogError(e, "Error creating user conversation: {ErrorMessage}", e.Message); return NotFound(e.Message); } - catch (MessageExistsException e) + catch (UserNotParticipantException e) + { + _logger.LogError(e, "Error creating user conversation: {ErrorMessage}", e.Message); + return new ObjectResult(e.Message) { StatusCode = 403 }; + } + catch (Exception e) when (e is MessageExistsException || e is UserConversationExistsException) { _logger.LogError(e, "Error creating user conversation: {ErrorMessage}", e.Message); return Conflict(e.Message); @@ -118,12 +127,16 @@ public async Task> GetMessages(string conversa conversationId, limit, orderBy, continuationToken, lastSeenConversationTime); _logger.LogInformation("Fetched messages from conversation {ConversationId}", conversationId); - - string nextUri = $"/api/conversations/{conversationId}/messages" + - $"&limit={limit}" + - $"&continuationToken={result.NextContinuationToken}" + - $"&lastSeenConversationTime={lastSeenConversationTime}"; - + + string nextUri = ""; + if (result.NextContinuationToken != null) + { + nextUri = $"/api/conversations/{conversationId}/messages" + + $"&limit={limit}" + + $"&continuationToken={result.NextContinuationToken}" + + $"&lastSeenConversationTime={lastSeenConversationTime}"; + } + GetMessagesResponse response = new GetMessagesResponse { Messages = result.Messages, diff --git a/ChatService.Web/Dtos/GetMessagesServiceResult.cs b/ChatService.Web/Dtos/GetMessagesServiceResult.cs index 292dbb2..46ca83f 100644 --- a/ChatService.Web/Dtos/GetMessagesServiceResult.cs +++ b/ChatService.Web/Dtos/GetMessagesServiceResult.cs @@ -5,5 +5,5 @@ namespace ChatService.Web.Dtos; public record GetMessagesServiceResult { [Required] public List Messages { get; set; } - [Required] public string NextContinuationToken { get; set; } + [Required] public string? NextContinuationToken { get; set; } } \ No newline at end of file diff --git a/ChatService.Web/Dtos/GetUserConversationsServiceResult.cs b/ChatService.Web/Dtos/GetUserConversationsServiceResult.cs index 4cc1d72..dae2d2c 100644 --- a/ChatService.Web/Dtos/GetUserConversationsServiceResult.cs +++ b/ChatService.Web/Dtos/GetUserConversationsServiceResult.cs @@ -5,5 +5,5 @@ namespace ChatService.Web.Dtos; public record GetUserConversationsServiceResult { [Required] public List Conversations { get; set; } - [Required] public string NextContinuationToken { get; set; } + [Required] public string? NextContinuationToken { get; set; } } \ No newline at end of file From 1f5971f82f09fafb24a60a15bf75073dd1adc871 Mon Sep 17 00:00:00 2001 From: nadimakk Date: Sat, 1 Apr 2023 22:16:26 +0300 Subject: [PATCH 44/45] bug fixes and test refactoring --- .../CosmosConversationStoreTests.cs | 18 ++- .../CosmosMessageStoreTests.cs | 12 +- .../ConversationsControllerTests.cs | 21 ++- .../Services/MessageServiceTests.cs | 10 +- .../Controllers/ConversationsController.cs | 18 +-- .../InvalidContinuationTokenException.cs | 8 ++ ChatService.Web/Services/MessageService.cs | 17 +-- ChatService.Web/Storage/BlobImageStore.cs | 22 +-- ChatService.Web/Storage/CosmosMessageStore.cs | 134 +++++++++++------- ChatService.Web/Storage/CosmosProfileStore.cs | 27 ++-- .../Storage/CosmosUserConversationStore.cs | 130 ++++++++++------- ChatService.Web/appsettings.json | 4 +- 12 files changed, 275 insertions(+), 146 deletions(-) create mode 100644 ChatService.Web/Exceptions/InvalidContinuationTokenException.cs diff --git a/ChatService.Web.IntegrationTests/CosmosConversationStoreTests.cs b/ChatService.Web.IntegrationTests/CosmosConversationStoreTests.cs index 88b8330..d60f705 100644 --- a/ChatService.Web.IntegrationTests/CosmosConversationStoreTests.cs +++ b/ChatService.Web.IntegrationTests/CosmosConversationStoreTests.cs @@ -14,28 +14,28 @@ public class CosmosConversationStoreTests : IClassFixture( () => _userConversationStore.GetUserConversations(username, limit, OrderBy.ASC, null, lastSeenConversationTime)); } + [Fact] + public async Task GetUserConversations_InvalidContinuationToken() + { + string invalidContinuationToken = Guid.NewGuid().ToString(); + + await Assert.ThrowsAsync( + () => _userConversationStore.GetUserConversations( + _userConversation.Username, 10, OrderBy.DESC, invalidContinuationToken, 0)); + } + private async Task AddMultipleUserConversations(params UserConversation[] userConversations) { foreach (UserConversation userConversation in userConversations) diff --git a/ChatService.Web.IntegrationTests/CosmosMessageStoreTests.cs b/ChatService.Web.IntegrationTests/CosmosMessageStoreTests.cs index 016e324..bce1098 100644 --- a/ChatService.Web.IntegrationTests/CosmosMessageStoreTests.cs +++ b/ChatService.Web.IntegrationTests/CosmosMessageStoreTests.cs @@ -11,7 +11,7 @@ public class CosmosMessageStoreTests : IClassFixture(() => _messageStore.GetMessages(conversationId, limit, OrderBy.ASC, null, lastSeenMessageTime)); } + + [Fact] + public async Task GetMessages_InvalidContinuationToken() + { + string invalidContinuationToken = Guid.NewGuid().ToString(); + + await Assert.ThrowsAsync( + () => _messageStore.GetMessages( + _conversationId, 10, OrderBy.DESC, invalidContinuationToken, 0)); + } [Fact] public async Task ConversationPartitionExists_Exists() diff --git a/ChatService.Web.Tests/Controllers/ConversationsControllerTests.cs b/ChatService.Web.Tests/Controllers/ConversationsControllerTests.cs index 2635c49..f233245 100644 --- a/ChatService.Web.Tests/Controllers/ConversationsControllerTests.cs +++ b/ChatService.Web.Tests/Controllers/ConversationsControllerTests.cs @@ -100,14 +100,31 @@ public async Task GetUserConversations_InvalidArguments() Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } + + [Fact] + public async Task GetUserConversations_InvalidContinuationToken() + { + string invalidContinuationToken = Guid.NewGuid().ToString(); + + _userConversationServiceMock.Setup(m => m.GetUserConversations( + _username, 10, OrderBy.DESC, invalidContinuationToken, 0)) + .ThrowsAsync(new InvalidContinuationTokenException($"Continuation token {invalidContinuationToken} is invalid.")); + + var response = await _httpClient.GetAsync( + $"api/Conversations/?username={_username}&continuationToken={invalidContinuationToken}"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } [Fact] public async Task GetUserConversations_UserNotFound() { - _userConversationServiceMock.Setup(m => m.GetUserConversations(_username, 10, OrderBy.DESC, null, 0)) + _userConversationServiceMock.Setup(m => m.GetUserConversations( + _username, 10, OrderBy.DESC, null, 0)) .ThrowsAsync(new UserNotFoundException($"User {_username} was not found.")); - var response = await _httpClient.GetAsync($"api/Conversations/?username={_username}"); + var response = await _httpClient.GetAsync( + $"api/Conversations/?username={_username}&"); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } diff --git a/ChatService.Web.Tests/Services/MessageServiceTests.cs b/ChatService.Web.Tests/Services/MessageServiceTests.cs index 1ff713b..7a13f7d 100644 --- a/ChatService.Web.Tests/Services/MessageServiceTests.cs +++ b/ChatService.Web.Tests/Services/MessageServiceTests.cs @@ -113,8 +113,14 @@ public async Task AddMessage_UserNotParticipant() { _sendMessageRequest.SenderUsername = Guid.NewGuid().ToString(); - await Assert.ThrowsAsync(() => _messageService.AddMessage( - _conversationId, true, _sendMessageRequest)); + _messageStoreMock.Setup(m => m.ConversationPartitionExists(_conversationId)) + .ReturnsAsync(true); + + _profileServiceMock.Setup(m => m.ProfileExists(_sendMessageRequest.SenderUsername)) + .ReturnsAsync(true); + + await Assert.ThrowsAsync( + () => _messageService.AddMessage(_conversationId, true, _sendMessageRequest)); } [Fact] diff --git a/ChatService.Web/Controllers/ConversationsController.cs b/ChatService.Web/Controllers/ConversationsController.cs index ca0b55d..8897b5d 100644 --- a/ChatService.Web/Controllers/ConversationsController.cs +++ b/ChatService.Web/Controllers/ConversationsController.cs @@ -36,28 +36,28 @@ public async Task> GetUserConversatio { GetUserConversationsServiceResult result = await _userConversationService.GetUserConversations( username, limit, orderBy, continuationToken, lastSeenConversationTime); - + _logger.LogInformation("Fetched conversations of user {Username}", username); string nextUri = ""; if (result.NextContinuationToken != null) { nextUri = "/api/conversations" + - $"?username={username}" + - $"&limit={limit}" + - $"&lastSeenConversationTime={lastSeenConversationTime}" + - $"&continuationToken={result.NextContinuationToken}"; + $"?username={username}" + + $"&limit={limit}" + + $"&lastSeenConversationTime={lastSeenConversationTime}" + + $"&continuationToken={result.NextContinuationToken}"; } - + GetUserConversationsResponse response = new GetUserConversationsResponse { Conversations = result.Conversations, NextUri = nextUri }; - + return Ok(response); } - catch (ArgumentException e) + catch (Exception e) when (e is ArgumentException || e is InvalidContinuationTokenException) { _logger.LogError(e, "Error getting user conversations: {ErrorMessage}", e.Message); return BadRequest(e.Message); @@ -145,7 +145,7 @@ public async Task> GetMessages(string conversa return Ok(response); } - catch (ArgumentException e) + catch (Exception e) when (e is ArgumentException || e is InvalidContinuationTokenException) { _logger.LogError(e, "Error getting messages: {ErrorMessage}", e.Message); return BadRequest(e.Message); 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/Services/MessageService.cs b/ChatService.Web/Services/MessageService.cs index 3b189e5..b39861f 100644 --- a/ChatService.Web/Services/MessageService.cs +++ b/ChatService.Web/Services/MessageService.cs @@ -21,13 +21,6 @@ public async Task AddMessage(string conversationId, bool is { ValidateSendMessageRequest(request); ValidateConversationId(conversationId); - AuthorizeSender(conversationId, request.SenderUsername); - - if (!await _profileService.ProfileExists(request.SenderUsername)) - { - throw new ProfileNotFoundException( - $"A profile with the username {request.SenderUsername} was not found."); - } if (!isFirstMessage && !await _messageStore.ConversationPartitionExists(conversationId)) { @@ -35,6 +28,14 @@ public async Task AddMessage(string conversationId, bool is $"A conversation partition with the conversationId {conversationId} does not exist."); } + if (!await _profileService.ProfileExists(request.SenderUsername)) + { + throw new ProfileNotFoundException( + $"A profile with the username {request.SenderUsername} was not found."); + } + + AuthorizeSender(conversationId, request.SenderUsername); + long unixTimeNow = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); Message message = new Message @@ -95,7 +96,7 @@ private void ValidateSendMessageRequest(SendMessageRequest request) private void ValidateConversationId(string conversationId) { - if (string.IsNullOrWhiteSpace(conversationId)) + if (string.IsNullOrWhiteSpace(conversationId) || !conversationId.Contains('_')) { throw new ArgumentException($"Invalid conversationId {conversationId}."); } diff --git a/ChatService.Web/Storage/BlobImageStore.cs b/ChatService.Web/Storage/BlobImageStore.cs index 24e4e60..ad60532 100644 --- a/ChatService.Web/Storage/BlobImageStore.cs +++ b/ChatService.Web/Storage/BlobImageStore.cs @@ -18,15 +18,8 @@ public BlobImageStore(BlobServiceClient blobServiceClient) public async Task UploadImage(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."); - } - + ValidateImage(image); + string imageId = Guid.NewGuid().ToString(); BlobClient blobClient = BlobContainerClient.GetBlobClient(imageId); BlobHttpHeaders headers = new BlobHttpHeaders @@ -71,4 +64,15 @@ 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 index c89d133..4490c2a 100644 --- a/ChatService.Web/Storage/CosmosMessageStore.cs +++ b/ChatService.Web/Storage/CosmosMessageStore.cs @@ -21,15 +21,7 @@ public CosmosMessageStore(CosmosClient cosmosClient) public async Task AddMessage(string conversationId, Message message) { - if (message == null || - string.IsNullOrWhiteSpace(message.MessageId) || - string.IsNullOrWhiteSpace(message.SenderUsername) || - string.IsNullOrWhiteSpace(message.Text) || - message.UnixTime < 0 - ) - { - throw new ArgumentException($"Invalid message {message}", nameof(message)); - } + ValidateMessage(message); try { @@ -47,15 +39,9 @@ public async Task AddMessage(string conversationId, Message message) public async Task GetMessage(string conversationId, string messageId) { - if (string.IsNullOrWhiteSpace(conversationId)) - { - throw new ArgumentException($"Invalid conversationId {conversationId}"); - } - if (string.IsNullOrWhiteSpace(messageId)) - { - throw new ArgumentException($"Invalid messageId {messageId}"); - } - + ValidateConversationId(conversationId); + ValidateMessageId(messageId); + try { var entity = await Container.ReadItemAsync( @@ -81,51 +67,51 @@ public async Task GetMessage(string conversationId, string messageId) public async Task<(List Messages, string NextContinuationToken)> GetMessages( string conversationId, int limit, OrderBy order, string? continuationToken, long lastSeenMessageTime) { - if (string.IsNullOrWhiteSpace(conversationId)) - { - throw new ArgumentException("ConversationId cannot be null or empty."); - } - if (limit <= 0) - { - throw new ArgumentException($"Invalid limit {limit}. Limit must be greater or equal to 1."); - } + ValidateConversationId(conversationId); + ValidateLimit(limit); + ValidateLastSeenMessageTime(lastSeenMessageTime); - if (lastSeenMessageTime < 0) - { - throw new ArgumentException( - $"Invalid lastSeenMessageTime {lastSeenMessageTime}. lastSeenMessageTime must be greater or equal to 0."); - } - List messages = new (); string? nextContinuationToken = null; QueryRequestOptions options = new QueryRequestOptions(); options.MaxItemCount = limit; - IQueryable query = Container - .GetItemLinqQueryable(false, continuationToken, options) - .Where(e => e.partitionKey == conversationId && e.UnixTime > lastSeenMessageTime); - - if (order == OrderBy.ASC) - { - query = query.OrderBy(e => e.UnixTime); - } - else + try { - query = query.OrderByDescending(e => e.UnixTime); - } + IQueryable query = Container + .GetItemLinqQueryable(false, continuationToken, options) + .Where(e => e.partitionKey == conversationId && e.UnixTime > lastSeenMessageTime); - using (FeedIterator iterator = query.ToFeedIterator()) - { - FeedResponse response = await iterator.ReadNextAsync(); - var receivedUserConversations = response.Select(ToMessage); + if (order == OrderBy.ASC) + { + query = query.OrderBy(e => e.UnixTime); + } + else + { + query = query.OrderByDescending(e => e.UnixTime); + } + + using (FeedIterator iterator = query.ToFeedIterator()) + { + FeedResponse response = await iterator.ReadNextAsync(); + var receivedUserConversations = response.Select(ToMessage); - messages.AddRange(receivedUserConversations); + messages.AddRange(receivedUserConversations); - nextContinuationToken = response.ContinuationToken; - }; + nextContinuationToken = response.ContinuationToken; + }; - return (messages, nextContinuationToken); + return (messages, nextContinuationToken); + } + catch (CosmosException e) + { + if (e.StatusCode == HttpStatusCode.BadRequest) + { + throw new InvalidContinuationTokenException($"Continuation token {continuationToken} is invalid."); + } + throw; + } } public async Task ConversationPartitionExists(string conversationId) @@ -175,4 +161,50 @@ private static Message ToMessage(MessageEntity entity) Text = entity.Text }; } + + private void ValidateMessage(Message message) + { + if (message == null || + string.IsNullOrWhiteSpace(message.MessageId) || + string.IsNullOrWhiteSpace(message.SenderUsername) || + string.IsNullOrWhiteSpace(message.Text) || + message.UnixTime < 0 + ) + { + throw new ArgumentException($"Invalid message {message}", nameof(message)); + } + } + + private void ValidateConversationId(string conversationId) + { + if (string.IsNullOrWhiteSpace(conversationId) || !conversationId.Contains('_')) + { + throw new ArgumentException($"Invalid conversationId {conversationId}."); + } + } + + private void ValidateMessageId(string messageId) + { + if (string.IsNullOrWhiteSpace(messageId)) + { + throw new ArgumentException($"Invalid messageId {messageId}"); + } + } + + private void ValidateLimit(int limit) + { + if (limit <= 0) + { + throw new ArgumentException($"Invalid limit {limit}. Limit must be greater or equal to 1."); + } + } + + private void ValidateLastSeenMessageTime(long lastSeenMessageTime) + { + if (lastSeenMessageTime < 0) + { + throw new ArgumentException( + $"Invalid lastSeenMessageTime {lastSeenMessageTime}. LastSeenMessageTime must be greater or equal to 0."); + } + } } \ No newline at end of file diff --git a/ChatService.Web/Storage/CosmosProfileStore.cs b/ChatService.Web/Storage/CosmosProfileStore.cs index a9eb861..80dc9f8 100644 --- a/ChatService.Web/Storage/CosmosProfileStore.cs +++ b/ChatService.Web/Storage/CosmosProfileStore.cs @@ -19,15 +19,7 @@ public CosmosProfileStore(CosmosClient cosmosClient) public async Task AddProfile(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)); - } + ValidateProfile(profile); try { @@ -113,4 +105,21 @@ private static Profile ToProfile(ProfileEntity entity) ProfilePictureId = entity.ProfilePictureId }; } + + private void ValidateProfile(Profile profile) + { + if (profile == null || + string.IsNullOrWhiteSpace(profile.Username) || + string.IsNullOrWhiteSpace(profile.FirstName) || + string.IsNullOrWhiteSpace(profile.LastName) || + string.IsNullOrWhiteSpace(profile.ProfilePictureId) + ) + { + throw new ArgumentException($"Invalid profile {profile}", nameof(profile)); + } + if (profile.Username.Contains('_')) + { + throw new InvalidUsernameException($"Username {profile.Username} is invalid. Usernames cannot have an underscore."); + } + } } \ No newline at end of file diff --git a/ChatService.Web/Storage/CosmosUserConversationStore.cs b/ChatService.Web/Storage/CosmosUserConversationStore.cs index abedf9b..4b7c8a1 100644 --- a/ChatService.Web/Storage/CosmosUserConversationStore.cs +++ b/ChatService.Web/Storage/CosmosUserConversationStore.cs @@ -21,14 +21,7 @@ public CosmosUserConversationStore(CosmosClient cosmosClient) public async Task CreateUserConversation(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)); - } + ValidateUserConversation(userConversation); try { @@ -46,15 +39,9 @@ public async Task CreateUserConversation(UserConversation userConversation) public async Task GetUserConversation(string username, string conversationId) { - if (string.IsNullOrWhiteSpace(username)) - { - throw new ArgumentException($"Invalid username {username}"); - } + ValidateUsername(username); + ValidateConversationId(conversationId); - if (string.IsNullOrWhiteSpace(conversationId)) - { - throw new ArgumentException($"Invalid conversationId {conversationId}"); - } try { var entity = await Container.ReadItemAsync( @@ -80,20 +67,9 @@ public async Task GetUserConversation(string username, string public async Task<(List UserConversations, string NextContinuationToken)> GetUserConversations (string username, int limit, OrderBy order, string? continuationToken, long lastSeenConversationTime) { - if (string.IsNullOrWhiteSpace(username)) - { - throw new ArgumentException("Username cannot be null or empty."); - } - if (limit <= 0) - { - throw new ArgumentException($"Invalid limit {limit}. Limit must be greater or equal to 1."); - } - - if (lastSeenConversationTime < 0) - { - throw new ArgumentException( - $"Invalid lastSeenConversationTime {lastSeenConversationTime}. lastSeenConversationTime must be greater or equal to 0."); - } + ValidateUsername(username); + ValidateLimit(limit); + ValidateLastSeenMessageTime(lastSeenConversationTime); List userConversations = new (); string? nextContinuationToken = null; @@ -101,30 +77,41 @@ public async Task GetUserConversation(string username, string QueryRequestOptions options = new QueryRequestOptions(); options.MaxItemCount = limit; - IQueryable query = Container - .GetItemLinqQueryable(false, continuationToken, options) - .Where(e => e.partitionKey == username && e.LastModifiedTime > lastSeenConversationTime); - - if (order == OrderBy.ASC) - { - query = query.OrderBy(e => e.LastModifiedTime); - } - else + try { - query = query.OrderByDescending(e => e.LastModifiedTime); - } + IQueryable query = Container + .GetItemLinqQueryable(false, continuationToken, options) + .Where(e => e.partitionKey == username && e.LastModifiedTime > lastSeenConversationTime); + + if (order == OrderBy.ASC) + { + query = query.OrderBy(e => e.LastModifiedTime); + } + else + { + query = query.OrderByDescending(e => e.LastModifiedTime); + } - using (FeedIterator iterator = query.ToFeedIterator()) - { - FeedResponse response = await iterator.ReadNextAsync(); - var receivedUserConversations = response.Select(ToUserConversation); + using (FeedIterator iterator = query.ToFeedIterator()) + { + FeedResponse response = await iterator.ReadNextAsync(); + var receivedUserConversations = response.Select(ToUserConversation); - userConversations.AddRange(receivedUserConversations); + userConversations.AddRange(receivedUserConversations); - nextContinuationToken = response.ContinuationToken; - }; + nextContinuationToken = response.ContinuationToken; + }; - return (userConversations, nextContinuationToken); + return (userConversations, nextContinuationToken); + } + catch (CosmosException e) + { + if (e.StatusCode == HttpStatusCode.BadRequest) + { + throw new InvalidContinuationTokenException($"Continuation token {continuationToken} is invalid."); + } + throw; + } } public async Task DeleteUserConversation(string username, string conversationId) @@ -162,4 +149,49 @@ private static UserConversation ToUserConversation(UserConversationEntity entity LastModifiedTime = entity.LastModifiedTime }; } + + private void ValidateUserConversation(UserConversation userConversation) + { + if (userConversation == null || + string.IsNullOrWhiteSpace(userConversation.Username) || + string.IsNullOrWhiteSpace(userConversation.ConversationId) || + userConversation.LastModifiedTime < 0 + ) + { + throw new ArgumentException($"Invalid user conversation {userConversation}", nameof(userConversation)); + } + } + + private void ValidateUsername(string username) + { + if (string.IsNullOrWhiteSpace(username)) + { + throw new ArgumentException($"Invalid username {username}"); + } + } + + private void ValidateConversationId(string conversationId) + { + if (string.IsNullOrWhiteSpace(conversationId) || !conversationId.Contains('_')) + { + throw new ArgumentException($"Invalid conversationId {conversationId}."); + } + } + + private void ValidateLimit(int limit) + { + if (limit <= 0) + { + throw new ArgumentException($"Invalid limit {limit}. Limit must be greater or equal to 1."); + } + } + + private void ValidateLastSeenMessageTime(long lastSeenConversationTime) + { + if (lastSeenConversationTime < 0) + { + throw new ArgumentException( + $"Invalid lastSeenConversationTime {lastSeenConversationTime}. LastSeenConversationTime must be greater or equal to 0."); + } + } } \ No newline at end of file diff --git a/ChatService.Web/appsettings.json b/ChatService.Web/appsettings.json index d6a643d..de986fb 100644 --- a/ChatService.Web/appsettings.json +++ b/ChatService.Web/appsettings.json @@ -12,11 +12,11 @@ }, "Cosmos": { - "ConnectionString": "" + "ConnectionString": "AccountEndpoint=https://chat-service.documents.azure.com:443/;AccountKey=bTPHlotBT0IYyYa4sFscBdFm3dzVpTexbtk8ygqhytijFYLpJAByf5CThrODbuDVvmTOcDio2ywEACDbCYlcOg==;" }, "BlobStorage": { - "ConnectionString": "" + "ConnectionString": "DefaultEndpointsProtocol=https;AccountName=naa137;AccountKey=p6VJPQjkAPm3/JxBIY1l7Yj9ewb4Fp2l+Aciwm969OgT57+70hT/jMKcyDIdIwIJaEijfqsxAOdf+AStE/MZDw==;EndpointSuffix=core.windows.net" }, "AllowedHosts": "*" From 4e95493ce461a3f7807e127af62c03a86c931b04 Mon Sep 17 00:00:00 2001 From: nadimakk Date: Sat, 1 Apr 2023 22:23:44 +0300 Subject: [PATCH 45/45] bug fix --- ChatService.Web/appsettings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ChatService.Web/appsettings.json b/ChatService.Web/appsettings.json index de986fb..d6a643d 100644 --- a/ChatService.Web/appsettings.json +++ b/ChatService.Web/appsettings.json @@ -12,11 +12,11 @@ }, "Cosmos": { - "ConnectionString": "AccountEndpoint=https://chat-service.documents.azure.com:443/;AccountKey=bTPHlotBT0IYyYa4sFscBdFm3dzVpTexbtk8ygqhytijFYLpJAByf5CThrODbuDVvmTOcDio2ywEACDbCYlcOg==;" + "ConnectionString": "" }, "BlobStorage": { - "ConnectionString": "DefaultEndpointsProtocol=https;AccountName=naa137;AccountKey=p6VJPQjkAPm3/JxBIY1l7Yj9ewb4Fp2l+Aciwm969OgT57+70hT/jMKcyDIdIwIJaEijfqsxAOdf+AStE/MZDw==;EndpointSuffix=core.windows.net" + "ConnectionString": "" }, "AllowedHosts": "*"