From 39aa54bb10da8b76a4021feb984b8aad0df6269b Mon Sep 17 00:00:00 2001 From: 杨宇千 Date: Sun, 18 Aug 2019 18:07:50 +0800 Subject: Add avatar service. --- Timeline.Tests/Helpers/MyTestLoggerFactory.cs | 14 ++ Timeline.Tests/Helpers/MyWebApplicationFactory.cs | 5 + Timeline.Tests/Mock/Data/TestDatabase.cs | 46 ++++++ Timeline.Tests/UserAvatarServiceTest.cs | 159 +++++++++++++++++++ Timeline/Entities/DatabaseContext.cs | 3 + Timeline/Entities/UserAvatar.cs | 18 +++ Timeline/Services/UserAvatarService.cs | 181 ++++++++++++++++++++++ Timeline/Timeline.csproj | 6 - Timeline/appsettings.Development.json | 2 +- Timeline/default-avatar.png | Bin 0 -> 26442 bytes art-src/user.svg | 4 + 11 files changed, 431 insertions(+), 7 deletions(-) create mode 100644 Timeline.Tests/Helpers/MyTestLoggerFactory.cs create mode 100644 Timeline.Tests/Mock/Data/TestDatabase.cs create mode 100644 Timeline.Tests/UserAvatarServiceTest.cs create mode 100644 Timeline/Entities/UserAvatar.cs create mode 100644 Timeline/Services/UserAvatarService.cs create mode 100644 Timeline/default-avatar.png create mode 100644 art-src/user.svg diff --git a/Timeline.Tests/Helpers/MyTestLoggerFactory.cs b/Timeline.Tests/Helpers/MyTestLoggerFactory.cs new file mode 100644 index 00000000..40c6a77e --- /dev/null +++ b/Timeline.Tests/Helpers/MyTestLoggerFactory.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using Xunit.Abstractions; + +namespace Timeline.Tests.Helpers +{ + public static class MyTestLoggerFactory + { + public static LoggerFactory Create(ITestOutputHelper outputHelper) + { + return new LoggerFactory(new[] { new XunitLoggerProvider(outputHelper) }); + } + } +} diff --git a/Timeline.Tests/Helpers/MyWebApplicationFactory.cs b/Timeline.Tests/Helpers/MyWebApplicationFactory.cs index dfadd1ae..e96d11fe 100644 --- a/Timeline.Tests/Helpers/MyWebApplicationFactory.cs +++ b/Timeline.Tests/Helpers/MyWebApplicationFactory.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using System; @@ -37,6 +38,10 @@ namespace Timeline.Tests.Helpers { var options = new DbContextOptionsBuilder() .UseSqlite(_databaseConnection) + .ConfigureWarnings(builder => + { + builder.Throw(RelationalEventId.QueryClientEvaluationWarning); + }) .Options; using (var context = new DatabaseContext(options)) diff --git a/Timeline.Tests/Mock/Data/TestDatabase.cs b/Timeline.Tests/Mock/Data/TestDatabase.cs new file mode 100644 index 00000000..09c77dce --- /dev/null +++ b/Timeline.Tests/Mock/Data/TestDatabase.cs @@ -0,0 +1,46 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using System; +using Timeline.Entities; + +namespace Timeline.Tests.Mock.Data +{ + public class TestDatabase : IDisposable + { + private readonly SqliteConnection _databaseConnection; + private readonly DatabaseContext _databaseContext; + + public TestDatabase() + { + _databaseConnection = new SqliteConnection("Data Source=:memory:;"); + _databaseConnection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(_databaseConnection) + .ConfigureWarnings(builder => + { + builder.Throw(RelationalEventId.QueryClientEvaluationWarning); + }) + .Options; + + _databaseContext = new DatabaseContext(options); + + // init with mock data + _databaseContext.Database.EnsureCreated(); + _databaseContext.Users.AddRange(MockUsers.Users); + _databaseContext.SaveChanges(); + } + + public void Dispose() + { + _databaseContext.Dispose(); + + _databaseConnection.Close(); + _databaseConnection.Dispose(); + } + + public SqliteConnection DatabaseConnection => _databaseConnection; + public DatabaseContext DatabaseContext => _databaseContext; + } +} diff --git a/Timeline.Tests/UserAvatarServiceTest.cs b/Timeline.Tests/UserAvatarServiceTest.cs new file mode 100644 index 00000000..a8e0562b --- /dev/null +++ b/Timeline.Tests/UserAvatarServiceTest.cs @@ -0,0 +1,159 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using System; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Timeline.Entities; +using Timeline.Services; +using Timeline.Tests.Helpers; +using Timeline.Tests.Mock.Data; +using Xunit; +using Xunit.Abstractions; + +namespace Timeline.Tests +{ + public class MockDefaultUserAvatarProvider : IDefaultUserAvatarProvider + { + public static Avatar Avatar { get; } = new Avatar { Type = "image/test", Data = Encoding.ASCII.GetBytes("test") }; + + public Task GetDefaultAvatar() + { + return Task.FromResult(Avatar); + } + } + + public class UserAvatarServiceTest : IDisposable, IClassFixture + { + private static Avatar MockAvatar { get; } = new Avatar + { + Type = "image/testaaa", + Data = Encoding.ASCII.GetBytes("amock") + }; + + private static Avatar MockAvatar2 { get; } = new Avatar + { + Type = "image/testbbb", + Data = Encoding.ASCII.GetBytes("bmock") + }; + + private readonly MockDefaultUserAvatarProvider _mockDefaultUserAvatarProvider; + + private readonly LoggerFactory _loggerFactory; + private readonly TestDatabase _database; + + private readonly UserAvatarService _service; + + public UserAvatarServiceTest(ITestOutputHelper outputHelper, MockDefaultUserAvatarProvider mockDefaultUserAvatarProvider) + { + _mockDefaultUserAvatarProvider = mockDefaultUserAvatarProvider; + + _loggerFactory = MyTestLoggerFactory.Create(outputHelper); + _database = new TestDatabase(); + + _service = new UserAvatarService(_loggerFactory.CreateLogger(), _database.DatabaseContext, _mockDefaultUserAvatarProvider); + } + + public void Dispose() + { + _loggerFactory.Dispose(); + _database.Dispose(); + } + + [Fact] + public void GetAvatar_ShouldThrow_ArgumentException() + { + // no need to await because arguments are checked syncronizedly. + _service.Invoking(s => s.GetAvatar(null)).Should().Throw() + .Where(e => e.ParamName == "username" && e.Message.Contains("null", StringComparison.OrdinalIgnoreCase)); + _service.Invoking(s => s.GetAvatar("")).Should().Throw() + .Where(e => e.ParamName == "username" && e.Message.Contains("empty", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void GetAvatar_ShouldThrow_UserNotExistException() + { + const string username = "usernotexist"; + _service.Awaiting(s => s.GetAvatar(username)).Should().Throw() + .Where(e => e.Username == username); + } + + [Fact] + public async Task GetAvatar_ShouldReturn_Default() + { + const string username = MockUsers.UserUsername; + (await _service.GetAvatar(username)).Should().BeEquivalentTo(await _mockDefaultUserAvatarProvider.GetDefaultAvatar()); + } + + [Fact] + public async Task GetAvatar_ShouldReturn_Data() + { + const string username = MockUsers.UserUsername; + + { + // create mock data + var context = _database.DatabaseContext; + var user = await context.Users.Where(u => u.Name == username).Include(u => u.Avatar).SingleAsync(); + user.Avatar = new UserAvatar + { + Type = MockAvatar.Type, + Data = MockAvatar.Data + }; + await context.SaveChangesAsync(); + } + + (await _service.GetAvatar(username)).Should().BeEquivalentTo(MockAvatar); + } + + [Fact] + public void SetAvatar_ShouldThrow_ArgumentException() + { + // no need to await because arguments are checked syncronizedly. + _service.Invoking(s => s.SetAvatar(null, MockAvatar)).Should().Throw() + .Where(e => e.ParamName == "username" && e.Message.Contains("null", StringComparison.OrdinalIgnoreCase)); + _service.Invoking(s => s.SetAvatar("", MockAvatar)).Should().Throw() + .Where(e => e.ParamName == "username" && e.Message.Contains("empty", StringComparison.OrdinalIgnoreCase)); + + _service.Invoking(s => s.SetAvatar("aaa", new Avatar { Type = null, Data = new[] { (byte)0x00 } })).Should().Throw() + .Where(e => e.ParamName == "avatar" && e.Message.Contains("null", StringComparison.OrdinalIgnoreCase)); + _service.Invoking(s => s.SetAvatar("aaa", new Avatar { Type = "", Data = new[] { (byte)0x00 } })).Should().Throw() + .Where(e => e.ParamName == "avatar" && e.Message.Contains("empty", StringComparison.OrdinalIgnoreCase)); + + _service.Invoking(s => s.SetAvatar("aaa", new Avatar { Type = "aaa", Data = null })).Should().Throw() + .Where(e => e.ParamName == "avatar" && e.Message.Contains("null", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void SetAvatar_ShouldThrow_UserNotExistException() + { + const string username = "usernotexist"; + _service.Awaiting(s => s.SetAvatar(username, MockAvatar)).Should().Throw() + .Where(e => e.Username == username); + } + + [Fact] + public async Task SetAvatar_Should_Work() + { + const string username = MockUsers.UserUsername; + + var user = await _database.DatabaseContext.Users.Where(u => u.Name == username).Include(u => u.Avatar).SingleAsync(); + + // create + await _service.SetAvatar(username, MockAvatar); + user.Avatar.Should().NotBeNull(); + user.Avatar.Type.Should().Be(MockAvatar.Type); + user.Avatar.Data.Should().Equal(MockAvatar.Data); + + // modify + await _service.SetAvatar(username, MockAvatar2); + user.Avatar.Should().NotBeNull(); + user.Avatar.Type.Should().Be(MockAvatar2.Type); + user.Avatar.Data.Should().Equal(MockAvatar2.Data); + + // delete + await _service.SetAvatar(username, null); + user.Avatar.Should().BeNull(); + } + } +} diff --git a/Timeline/Entities/DatabaseContext.cs b/Timeline/Entities/DatabaseContext.cs index 3629e821..f32e5992 100644 --- a/Timeline/Entities/DatabaseContext.cs +++ b/Timeline/Entities/DatabaseContext.cs @@ -27,6 +27,8 @@ namespace Timeline.Entities [Column("version"), Required] public long Version { get; set; } + + public UserAvatar Avatar { get; set; } } public class DatabaseContext : DbContext @@ -44,5 +46,6 @@ namespace Timeline.Entities } public DbSet Users { get; set; } + public DbSet UserAvatars { get; set; } } } diff --git a/Timeline/Entities/UserAvatar.cs b/Timeline/Entities/UserAvatar.cs new file mode 100644 index 00000000..d7c24403 --- /dev/null +++ b/Timeline/Entities/UserAvatar.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Timeline.Entities +{ + [Table("user_avatars")] + public class UserAvatar + { + [Column("id"), Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public long Id { get; set; } + + [Column("data"), Required] + public byte[] Data { get; set; } + + [Column("type"), Required] + public string Type { get; set; } + } +} diff --git a/Timeline/Services/UserAvatarService.cs b/Timeline/Services/UserAvatarService.cs new file mode 100644 index 00000000..21153575 --- /dev/null +++ b/Timeline/Services/UserAvatarService.cs @@ -0,0 +1,181 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Timeline.Entities; + +namespace Timeline.Services +{ + public class Avatar + { + public string Type { get; set; } + public byte[] Data { get; set; } + } + + /// + /// Thrown when avatar is of bad format. + /// + [Serializable] + public class AvatarDataException : Exception + { + public AvatarDataException(Avatar avatar, string message) : base(message) { Avatar = avatar; } + public AvatarDataException(Avatar avatar, string message, Exception inner) : base(message, inner) { Avatar = avatar; } + protected AvatarDataException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + + public Avatar Avatar { get; set; } + } + + /// + /// Provider for default user avatar. + /// + /// + /// Mainly for unit tests. + /// + public interface IDefaultUserAvatarProvider + { + /// + /// Get the default avatar. + /// + Task GetDefaultAvatar(); + } + + public interface IUserAvatarService + { + /// + /// Get avatar of a user. If the user has no avatar, a default one is returned. + /// + /// The username of the user to get avatar of. + /// The avatar. + /// Thrown if is null or empty. + /// Thrown if the user does not exist. + Task GetAvatar(string username); + + /// + /// Set avatar for a user. + /// + /// The username of the user to set avatar for. + /// The avatar. Can be null to delete the saved avatar. + /// Throw if is null or empty. + /// Or thrown if is not null but is null or empty or is null. + /// Thrown if the user does not exist. + /// Thrown if avatar is of bad format. + Task SetAvatar(string username, Avatar avatar); + } + + public class DefaultUserAvatarProvider : IDefaultUserAvatarProvider + { + private readonly IHostingEnvironment _environment; + + public DefaultUserAvatarProvider(IHostingEnvironment environment) + { + _environment = environment; + } + + public async Task GetDefaultAvatar() + { + return new Avatar + { + Type = "image/png", + Data = await File.ReadAllBytesAsync(Path.Combine(_environment.ContentRootPath, "default-avatar.png")) + }; + } + } + + public class UserAvatarService : IUserAvatarService + { + + private readonly ILogger _logger; + + private readonly DatabaseContext _database; + + private readonly IDefaultUserAvatarProvider _defaultUserAvatarProvider; + + public UserAvatarService(ILogger logger, DatabaseContext database, IDefaultUserAvatarProvider defaultUserAvatarProvider) + { + _logger = logger; + _database = database; + _defaultUserAvatarProvider = defaultUserAvatarProvider; + } + + public async Task GetAvatar(string username) + { + if (string.IsNullOrEmpty(username)) + throw new ArgumentException("Username is null or empty.", nameof(username)); + + var user = await _database.Users.Where(u => u.Name == username).SingleOrDefaultAsync(); + if (user == null) + throw new UserNotExistException(username); + + await _database.Entry(user).Reference(u => u.Avatar).LoadAsync(); + var avatar = user.Avatar; + + if (avatar == null) + { + return await _defaultUserAvatarProvider.GetDefaultAvatar(); + } + else + { + return new Avatar + { + Type = avatar.Type, + Data = avatar.Data + }; + } + } + + public async Task SetAvatar(string username, Avatar avatar) + { + if (string.IsNullOrEmpty(username)) + throw new ArgumentException("Username is null or empty.", nameof(username)); + + if (avatar != null) + { + if (string.IsNullOrEmpty(avatar.Type)) + throw new ArgumentException("Type of avatar is null or empty.", nameof(avatar)); + if (avatar.Data == null) + throw new ArgumentException("Data of avatar is null.", nameof(avatar)); + } + + var user = await _database.Users.Where(u => u.Name == username).SingleOrDefaultAsync(); + if (user == null) + throw new UserNotExistException(username); + + await _database.Entry(user).Reference(u => u.Avatar).LoadAsync(); + var avatarEntity = user.Avatar; + + if (avatar == null) + { + if (avatarEntity == null) + return; + else + { + _database.UserAvatars.Remove(avatarEntity); + await _database.SaveChangesAsync(); + } + } + else + { + // TODO: Use image library to check the format to prohibit bad data. + if (avatarEntity == null) + { + user.Avatar = new UserAvatar + { + Type = avatar.Type, + Data = avatar.Data + }; + } + else + { + avatarEntity.Type = avatar.Type; + avatarEntity.Data = avatar.Data; + } + await _database.SaveChangesAsync(); + } + } + } +} diff --git a/Timeline/Timeline.csproj b/Timeline/Timeline.csproj index 1f70c634..29ff3354 100644 --- a/Timeline/Timeline.csproj +++ b/Timeline/Timeline.csproj @@ -5,12 +5,6 @@ 1f6fb74d-4277-4bc0-aeea-b1fc5ffb0b43 crupest - - - - - - diff --git a/Timeline/appsettings.Development.json b/Timeline/appsettings.Development.json index db4b074a..424b3885 100644 --- a/Timeline/appsettings.Development.json +++ b/Timeline/appsettings.Development.json @@ -7,6 +7,6 @@ } }, "JwtConfig": { - "SigningKey": "crupest hahahahahahahhahahahahaha" + "SigningKey": "this is very very very very very long secret" } } diff --git a/Timeline/default-avatar.png b/Timeline/default-avatar.png new file mode 100644 index 00000000..4086e1d2 Binary files /dev/null and b/Timeline/default-avatar.png differ diff --git a/art-src/user.svg b/art-src/user.svg new file mode 100644 index 00000000..acbd3c8a --- /dev/null +++ b/art-src/user.svg @@ -0,0 +1,4 @@ + + + + -- cgit v1.2.3