diff options
author | 杨宇千 <crupest@outlook.com> | 2019-11-20 18:21:17 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2019-11-20 18:21:17 +0800 |
commit | 751467deb8ae18909ebd2b241bbb64f1f9da8295 (patch) | |
tree | 788b8acdf1141c757cb3226d3cd5f64594386b8f | |
parent | 2de7fa95bb5ad0a10f74fb390bac464a250dee42 (diff) | |
parent | 33318b9244a82fee6d711aa15f853e1590ff13f7 (diff) | |
download | timeline-751467deb8ae18909ebd2b241bbb64f1f9da8295.tar.gz timeline-751467deb8ae18909ebd2b241bbb64f1f9da8295.tar.bz2 timeline-751467deb8ae18909ebd2b241bbb64f1f9da8295.zip |
Merge pull request #54 from crupest/timeline
Add core feature Timeline (currently only personal timeline)
54 files changed, 3243 insertions, 518 deletions
diff --git a/Timeline.Tests/Controllers/PersonalTimelineControllerTest.cs b/Timeline.Tests/Controllers/PersonalTimelineControllerTest.cs new file mode 100644 index 00000000..372ba8a7 --- /dev/null +++ b/Timeline.Tests/Controllers/PersonalTimelineControllerTest.cs @@ -0,0 +1,388 @@ +using FluentAssertions;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using System;
+using System.Collections.Generic;
+using System.Reflection;
+using System.Threading.Tasks;
+using Timeline.Controllers;
+using Timeline.Filters;
+using Timeline.Models;
+using Timeline.Models.Http;
+using Timeline.Models.Validation;
+using Timeline.Services;
+using Timeline.Tests.Helpers;
+using Xunit;
+
+namespace Timeline.Tests.Controllers
+{
+ public class PersonalTimelineControllerTest : IDisposable
+ {
+ private readonly Mock<IPersonalTimelineService> _service;
+
+ private readonly PersonalTimelineController _controller;
+
+ public PersonalTimelineControllerTest()
+ {
+ _service = new Mock<IPersonalTimelineService>();
+ _controller = new PersonalTimelineController(NullLogger<PersonalTimelineController>.Instance, _service.Object);
+ }
+
+ public void Dispose()
+ {
+ _controller.Dispose();
+ }
+
+ [Fact]
+ public void AttributeTest()
+ {
+ static void AssertUsernameParameter(MethodInfo m)
+ {
+ m.GetParameter("username")
+ .Should().BeDecoratedWith<FromRouteAttribute>()
+ .And.BeDecoratedWith<UsernameAttribute>();
+ }
+
+ static void AssertBodyParamter<TBody>(MethodInfo m)
+ {
+ var p = m.GetParameter("body");
+ p.Should().BeDecoratedWith<FromBodyAttribute>();
+ p.ParameterType.Should().Be(typeof(TBody));
+ }
+
+ var type = typeof(PersonalTimelineController);
+ type.Should().BeDecoratedWith<ApiControllerAttribute>();
+
+ {
+ var m = type.GetMethod(nameof(PersonalTimelineController.TimelineGet));
+ m.Should().BeDecoratedWith<CatchTimelineNotExistExceptionAttribute>()
+ .And.BeDecoratedWith<HttpGetAttribute>();
+ AssertUsernameParameter(m);
+ }
+
+ {
+ var m = type.GetMethod(nameof(PersonalTimelineController.PostListGet));
+ m.Should().BeDecoratedWith<CatchTimelineNotExistExceptionAttribute>()
+ .And.BeDecoratedWith<HttpGetAttribute>();
+ AssertUsernameParameter(m);
+ }
+
+ {
+ var m = type.GetMethod(nameof(PersonalTimelineController.PostOperationCreate));
+ m.Should().BeDecoratedWith<CatchTimelineNotExistExceptionAttribute>()
+ .And.BeDecoratedWith<AuthorizeAttribute>()
+ .And.BeDecoratedWith<HttpPostAttribute>();
+ AssertUsernameParameter(m);
+ AssertBodyParamter<TimelinePostCreateRequest>(m);
+ }
+
+ {
+ var m = type.GetMethod(nameof(PersonalTimelineController.PostOperationDelete));
+ m.Should().BeDecoratedWith<CatchTimelineNotExistExceptionAttribute>()
+ .And.BeDecoratedWith<AuthorizeAttribute>()
+ .And.BeDecoratedWith<HttpPostAttribute>();
+ AssertUsernameParameter(m);
+ AssertBodyParamter<TimelinePostDeleteRequest>(m);
+ }
+
+ {
+ var m = type.GetMethod(nameof(PersonalTimelineController.TimelineChangeProperty));
+ m.Should().BeDecoratedWith<CatchTimelineNotExistExceptionAttribute>()
+ .And.BeDecoratedWith<AuthorizeAttribute>()
+ .And.BeDecoratedWith<SelfOrAdminAttribute>()
+ .And.BeDecoratedWith<HttpPostAttribute>();
+ AssertUsernameParameter(m);
+ AssertBodyParamter<TimelinePropertyChangeRequest>(m);
+ }
+
+ {
+ var m = type.GetMethod(nameof(PersonalTimelineController.TimelineChangeMember));
+ m.Should().BeDecoratedWith<CatchTimelineNotExistExceptionAttribute>()
+ .And.BeDecoratedWith<AuthorizeAttribute>()
+ .And.BeDecoratedWith<SelfOrAdminAttribute>()
+ .And.BeDecoratedWith<HttpPostAttribute>();
+ AssertUsernameParameter(m);
+ AssertBodyParamter<TimelineMemberChangeRequest>(m);
+ }
+ }
+
+ const string authUsername = "authuser";
+ private void SetUser(bool administrator)
+ {
+ _controller.ControllerContext = new ControllerContext
+ {
+ HttpContext = new DefaultHttpContext
+ {
+ User = PrincipalHelper.Create(authUsername, administrator)
+ }
+ };
+ }
+
+ [Fact]
+ public async Task TimelineGet()
+ {
+ const string username = "username";
+ var timelineInfo = new BaseTimelineInfo();
+ _service.Setup(s => s.GetTimeline(username)).ReturnsAsync(timelineInfo);
+ (await _controller.TimelineGet(username)).Value.Should().Be(timelineInfo);
+ _service.VerifyAll();
+ }
+
+ [Fact]
+ public async Task PostListGet_Forbid()
+ {
+ const string username = "username";
+ SetUser(false);
+ _service.Setup(s => s.HasReadPermission(username, authUsername)).ReturnsAsync(false);
+ var result = (await _controller.PostListGet(username)).Result
+ .Should().BeAssignableTo<ObjectResult>()
+ .Which;
+ result.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
+ result.Value.Should().BeAssignableTo<CommonResponse>()
+ .Which.Code.Should().Be(ErrorCodes.Http.Timeline.PostListGetForbid);
+ _service.VerifyAll();
+ }
+
+ [Fact]
+ public async Task PostListGet_Admin_Success()
+ {
+ const string username = "username";
+ SetUser(true);
+ _service.Setup(s => s.GetPosts(username)).ReturnsAsync(new List<TimelinePostInfo>());
+ (await _controller.PostListGet(username)).Value
+ .Should().BeAssignableTo<IList<TimelinePostInfo>>()
+ .Which.Should().NotBeNull().And.BeEmpty();
+ _service.VerifyAll();
+ }
+
+ [Fact]
+ public async Task PostListGet_User_Success()
+ {
+ const string username = "username";
+ SetUser(false);
+ _service.Setup(s => s.HasReadPermission(username, authUsername)).ReturnsAsync(true);
+ _service.Setup(s => s.GetPosts(username)).ReturnsAsync(new List<TimelinePostInfo>());
+ (await _controller.PostListGet(username)).Value
+ .Should().BeAssignableTo<IList<TimelinePostInfo>>()
+ .Which.Should().NotBeNull().And.BeEmpty();
+ _service.VerifyAll();
+ }
+
+ [Fact]
+ public async Task PostOperationCreate_Forbid()
+ {
+ const string username = "username";
+ const string content = "cccc";
+ SetUser(false);
+ _service.Setup(s => s.IsMemberOf(username, authUsername)).ReturnsAsync(false);
+ var result = (await _controller.PostOperationCreate(username, new TimelinePostCreateRequest
+ {
+ Content = content,
+ Time = null
+ })).Result.Should().NotBeNull().And.BeAssignableTo<ObjectResult>().Which;
+ result.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
+ result.Value.Should().BeAssignableTo<CommonResponse>()
+ .Which.Code.Should().Be(ErrorCodes.Http.Timeline.PostOperationCreateForbid);
+ _service.VerifyAll();
+ }
+
+ [Fact]
+ public async Task PostOperationCreate_Admin_Success()
+ {
+ const string username = "username";
+ const string content = "cccc";
+ var response = new TimelinePostCreateResponse
+ {
+ Id = 3,
+ Time = DateTime.Now
+ };
+ SetUser(true);
+ _service.Setup(s => s.CreatePost(username, authUsername, content, null)).ReturnsAsync(response);
+ var resultValue = (await _controller.PostOperationCreate(username, new TimelinePostCreateRequest
+ {
+ Content = content,
+ Time = null
+ })).Value;
+ resultValue.Should().NotBeNull()
+ .And.BeAssignableTo<TimelinePostCreateResponse>()
+ .And.BeEquivalentTo(response);
+ _service.VerifyAll();
+ }
+
+ [Fact]
+ public async Task PostOperationCreate_User_Success()
+ {
+ const string username = "username";
+ const string content = "cccc";
+ var response = new TimelinePostCreateResponse
+ {
+ Id = 3,
+ Time = DateTime.Now
+ };
+ SetUser(false);
+ _service.Setup(s => s.IsMemberOf(username, authUsername)).ReturnsAsync(true);
+ _service.Setup(s => s.CreatePost(username, authUsername, content, null)).ReturnsAsync(response);
+ var resultValue = (await _controller.PostOperationCreate(username, new TimelinePostCreateRequest
+ {
+ Content = content,
+ Time = null
+ })).Value;
+ resultValue.Should().NotBeNull()
+ .And.BeAssignableTo<TimelinePostCreateResponse>()
+ .And.BeEquivalentTo(response);
+ _service.VerifyAll();
+ }
+
+ [Fact]
+ public async Task PostOperationDelete_Forbid()
+ {
+ const string username = "username";
+ const long postId = 2;
+ SetUser(false);
+ _service.Setup(s => s.HasPostModifyPermission(username, postId, authUsername)).ReturnsAsync(false);
+ var result = (await _controller.PostOperationDelete(username, new TimelinePostDeleteRequest
+ {
+ Id = postId
+ })).Should().NotBeNull().And.BeAssignableTo<ObjectResult>().Which;
+ result.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
+ result.Value.Should().BeAssignableTo<CommonResponse>()
+ .Which.Code.Should().Be(ErrorCodes.Http.Timeline.PostOperationDeleteForbid);
+ _service.VerifyAll();
+ }
+
+ [Fact]
+ public async Task PostOperationDelete_NotExist()
+ {
+ const string username = "username";
+ const long postId = 2;
+ SetUser(true);
+ _service.Setup(s => s.DeletePost(username, postId)).ThrowsAsync(new TimelinePostNotExistException());
+ var result = (await _controller.PostOperationDelete(username, new TimelinePostDeleteRequest
+ {
+ Id = postId
+ })).Should().NotBeNull().And.BeAssignableTo<ObjectResult>().Which;
+ result.StatusCode.Should().Be(StatusCodes.Status400BadRequest);
+ result.Value.Should().BeAssignableTo<CommonResponse>()
+ .Which.Code.Should().Be(ErrorCodes.Http.Timeline.PostOperationDeleteNotExist);
+ _service.VerifyAll();
+ }
+
+ [Fact]
+ public async Task PostOperationDelete_Admin_Success()
+ {
+ const string username = "username";
+ const long postId = 2;
+ SetUser(true);
+ _service.Setup(s => s.DeletePost(username, postId)).Returns(Task.CompletedTask);
+ var result = await _controller.PostOperationDelete(username, new TimelinePostDeleteRequest
+ {
+ Id = postId
+ });
+ result.Should().NotBeNull().And.BeAssignableTo<OkResult>();
+ _service.VerifyAll();
+ }
+
+ [Fact]
+ public async Task PostOperationDelete_User_Success()
+ {
+ const string username = "username";
+ const long postId = 2;
+ SetUser(false);
+ _service.Setup(s => s.DeletePost(username, postId)).Returns(Task.CompletedTask);
+ _service.Setup(s => s.HasPostModifyPermission(username, postId, authUsername)).ReturnsAsync(true);
+ var result = await _controller.PostOperationDelete(username, new TimelinePostDeleteRequest
+ {
+ Id = postId
+ });
+ result.Should().NotBeNull().And.BeAssignableTo<OkResult>();
+ _service.VerifyAll();
+ }
+
+ [Fact]
+ public async Task TimelineChangeProperty_Success()
+ {
+ const string username = "username";
+ var req = new TimelinePropertyChangeRequest
+ {
+ Description = "",
+ Visibility = TimelineVisibility.Private
+ };
+ _service.Setup(s => s.ChangeProperty(username, req)).Returns(Task.CompletedTask);
+ var result = await _controller.TimelineChangeProperty(username, req);
+ result.Should().NotBeNull().And.BeAssignableTo<OkResult>();
+ _service.VerifyAll();
+ }
+
+ [Fact]
+ public async Task TimelineChangeMember_Success()
+ {
+ const string username = "username";
+ var add = new List<string> { "aaa" };
+ var remove = new List<string> { "rrr" };
+ _service.Setup(s => s.ChangeMember(username, add, remove)).Returns(Task.CompletedTask);
+ var result = await _controller.TimelineChangeMember(username, new TimelineMemberChangeRequest
+ {
+ Add = add,
+ Remove = remove
+ });
+ result.Should().NotBeNull().And.BeAssignableTo<OkResult>();
+ _service.VerifyAll();
+ }
+
+ [Fact]
+ public async Task TimelineChangeMember_UsernameBadFormat()
+ {
+ const string username = "username";
+ var add = new List<string> { "aaa" };
+ var remove = new List<string> { "rrr" };
+ _service.Setup(s => s.ChangeMember(username, add, remove)).ThrowsAsync(
+ new TimelineMemberOperationUserException("test", new UsernameBadFormatException()));
+ var result = await _controller.TimelineChangeMember(username, new TimelineMemberChangeRequest
+ {
+ Add = add,
+ Remove = remove
+ });
+ result.Should().NotBeNull().And.BeAssignableTo<BadRequestObjectResult>()
+ .Which.Value.Should().BeAssignableTo<CommonResponse>()
+ .Which.Code.Should().Be(ErrorCodes.Http.Common.InvalidModel);
+ _service.VerifyAll();
+ }
+
+ [Fact]
+ public async Task TimelineChangeMember_AddNotExist()
+ {
+ const string username = "username";
+ var add = new List<string> { "aaa" };
+ var remove = new List<string> { "rrr" };
+ _service.Setup(s => s.ChangeMember(username, add, remove)).ThrowsAsync(
+ new TimelineMemberOperationUserException("test", new UserNotExistException()));
+ var result = await _controller.TimelineChangeMember(username, new TimelineMemberChangeRequest
+ {
+ Add = add,
+ Remove = remove
+ });
+ result.Should().NotBeNull().And.BeAssignableTo<BadRequestObjectResult>()
+ .Which.Value.Should().BeAssignableTo<CommonResponse>()
+ .Which.Code.Should().Be(ErrorCodes.Http.Timeline.ChangeMemberUserNotExist);
+ _service.VerifyAll();
+ }
+
+ [Fact]
+ public async Task TimelineChangeMember_UnknownTimelineMemberOperationUserException()
+ {
+ const string username = "username";
+ var add = new List<string> { "aaa" };
+ var remove = new List<string> { "rrr" };
+ _service.Setup(s => s.ChangeMember(username, add, remove)).ThrowsAsync(
+ new TimelineMemberOperationUserException("test", null));
+ await _controller.Awaiting(c => c.TimelineChangeMember(username, new TimelineMemberChangeRequest
+ {
+ Add = add,
+ Remove = remove
+ })).Should().ThrowAsync<TimelineMemberOperationUserException>(); // Should rethrow.
+ }
+ }
+}
diff --git a/Timeline.Tests/Controllers/TokenControllerTest.cs b/Timeline.Tests/Controllers/TokenControllerTest.cs index 4a08ca0f..238fc237 100644 --- a/Timeline.Tests/Controllers/TokenControllerTest.cs +++ b/Timeline.Tests/Controllers/TokenControllerTest.cs @@ -8,8 +8,7 @@ using System.Threading.Tasks; using Timeline.Controllers;
using Timeline.Models.Http;
using Timeline.Services;
-using Timeline.Tests.Mock.Data;
-using Timeline.Tests.Mock.Services;
+using Timeline.Tests.Helpers;
using Xunit;
using static Timeline.ErrorCodes.Http.Token;
diff --git a/Timeline.Tests/Controllers/UserControllerTest.cs b/Timeline.Tests/Controllers/UserControllerTest.cs index 83b8cdcf..a5ca7a2b 100644 --- a/Timeline.Tests/Controllers/UserControllerTest.cs +++ b/Timeline.Tests/Controllers/UserControllerTest.cs @@ -12,7 +12,6 @@ using Timeline.Models; using Timeline.Models.Http;
using Timeline.Services;
using Timeline.Tests.Helpers;
-using Timeline.Tests.Mock.Data;
using Xunit;
using static Timeline.ErrorCodes.Http.User;
diff --git a/Timeline.Tests/DatabaseTest.cs b/Timeline.Tests/DatabaseTest.cs index fc153c24..a7b97c16 100644 --- a/Timeline.Tests/DatabaseTest.cs +++ b/Timeline.Tests/DatabaseTest.cs @@ -2,7 +2,7 @@ using System;
using System.Linq;
using Timeline.Entities;
-using Timeline.Tests.Mock.Data;
+using Timeline.Tests.Helpers;
using Xunit;
namespace Timeline.Tests
@@ -15,7 +15,7 @@ namespace Timeline.Tests public DatabaseTest()
{
_database = new TestDatabase();
- _context = _database.DatabaseContext;
+ _context = _database.Context;
}
public void Dispose()
diff --git a/Timeline.Tests/Helpers/Authentication/AuthenticationExtensions.cs b/Timeline.Tests/Helpers/Authentication/AuthenticationExtensions.cs deleted file mode 100644 index 34d7e460..00000000 --- a/Timeline.Tests/Helpers/Authentication/AuthenticationExtensions.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Microsoft.AspNetCore.Mvc.Testing;
-using Newtonsoft.Json;
-using System.Net.Http;
-using System.Threading.Tasks;
-using Timeline.Models.Http;
-using Timeline.Tests.Mock.Data;
-
-namespace Timeline.Tests.Helpers.Authentication
-{
- public static class AuthenticationExtensions
- {
- private const string CreateTokenUrl = "/token/create";
-
- public static async Task<CreateTokenResponse> CreateUserTokenAsync(this HttpClient client, string username, string password, int? expireOffset = null)
- {
- var response = await client.PostAsJsonAsync(CreateTokenUrl, new CreateTokenRequest { Username = username, Password = password, Expire = expireOffset });
- response.Should().HaveStatusCode(200);
- var result = JsonConvert.DeserializeObject<CreateTokenResponse>(await response.Content.ReadAsStringAsync());
- return result;
- }
-
- public static async Task<HttpClient> CreateClientWithCredential<T>(this WebApplicationFactory<T> factory, string username, string password) where T : class
- {
- var client = factory.CreateDefaultClient();
- var token = (await client.CreateUserTokenAsync(username, password)).Token;
- client.DefaultRequestHeaders.Add("Authorization", "Bearer " + token);
- return client;
- }
-
- public static Task<HttpClient> CreateClientAsUser<T>(this WebApplicationFactory<T> factory) where T : class
- {
- return factory.CreateClientWithCredential(MockUser.User.Username, MockUser.User.Password);
- }
-
- public static Task<HttpClient> CreateClientAsAdmin<T>(this WebApplicationFactory<T> factory) where T : class
- {
- return factory.CreateClientWithCredential(MockUser.Admin.Username, MockUser.Admin.Password);
- }
- }
-}
diff --git a/Timeline.Tests/Helpers/MockUser.cs b/Timeline.Tests/Helpers/MockUser.cs new file mode 100644 index 00000000..8d738525 --- /dev/null +++ b/Timeline.Tests/Helpers/MockUser.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using Timeline.Models; + +namespace Timeline.Tests.Helpers +{ + public class MockUser + { + public MockUser(string username, string password, bool administrator) + { + Info = new UserInfo(username, administrator); + Password = password; + } + + public UserInfo Info { get; set; } + public string Username => Info.Username; + public string Password { get; set; } + public bool Administrator => Info.Administrator; + + public static MockUser User { get; } = new MockUser("user", "userpassword", false); + public static MockUser Admin { get; } = new MockUser("admin", "adminpassword", true); + + public static IReadOnlyList<UserInfo> UserInfoList { get; } = new List<UserInfo> { User.Info, Admin.Info }; + } +} diff --git a/Timeline.Tests/Helpers/PrincipalHelper.cs b/Timeline.Tests/Helpers/PrincipalHelper.cs new file mode 100644 index 00000000..89f3f7b1 --- /dev/null +++ b/Timeline.Tests/Helpers/PrincipalHelper.cs @@ -0,0 +1,23 @@ +using System.Linq; +using System.Security.Claims; +using Timeline.Models; + +namespace Timeline.Tests.Helpers +{ + public static class PrincipalHelper + { + internal const string AuthScheme = "TESTAUTH"; + + internal static ClaimsPrincipal Create(string username, bool administrator) + { + var identity = new ClaimsIdentity(AuthScheme); + identity.AddClaim(new Claim(identity.NameClaimType, username, ClaimValueTypes.String)); + identity.AddClaims(UserRoleConvert.ToArray(administrator).Select(role => new Claim(identity.RoleClaimType, role, ClaimValueTypes.String))); + + var principal = new ClaimsPrincipal(); + principal.AddIdentity(identity); + + return principal; + } + } +} diff --git a/Timeline.Tests/Helpers/ResponseAssertions.cs b/Timeline.Tests/Helpers/ResponseAssertions.cs index 0e6f215b..6d764c68 100644 --- a/Timeline.Tests/Helpers/ResponseAssertions.cs +++ b/Timeline.Tests/Helpers/ResponseAssertions.cs @@ -88,7 +88,7 @@ namespace Timeline.Tests.Helpers return new AndWhichConstraint<HttpResponseMessageAssertions, T>(this, null);
}
- var result = JsonConvert.DeserializeObject<T>(body);
+ var result = JsonConvert.DeserializeObject<T>(body); // TODO! catch and throw on bad format
return new AndWhichConstraint<HttpResponseMessageAssertions, T>(this, result);
}
}
diff --git a/Timeline.Tests/Helpers/TestApplication.cs b/Timeline.Tests/Helpers/TestApplication.cs index b0187a30..a624da6b 100644 --- a/Timeline.Tests/Helpers/TestApplication.cs +++ b/Timeline.Tests/Helpers/TestApplication.cs @@ -1,35 +1,18 @@ using Microsoft.AspNetCore.Mvc.Testing;
-using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System;
using Timeline.Entities;
-using Timeline.Tests.Mock.Data;
namespace Timeline.Tests.Helpers
{
public class TestApplication : IDisposable
{
- public SqliteConnection DatabaseConnection { get; } = new SqliteConnection("Data Source=:memory:;");
+ public TestDatabase Database { get; } = new TestDatabase();
public WebApplicationFactory<Startup> Factory { get; }
public TestApplication(WebApplicationFactory<Startup> factory)
{
- // We should keep the connection, so the database is persisted but not recreate every time.
- // See https://docs.microsoft.com/en-us/ef/core/miscellaneous/testing/sqlite#writing-tests .
- DatabaseConnection.Open();
-
- {
- var options = new DbContextOptionsBuilder<DatabaseContext>()
- .UseSqlite(DatabaseConnection)
- .Options;
-
- using (var context = new DatabaseContext(options))
- {
- TestDatabase.InitDatabase(context);
- };
- }
-
Factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
@@ -37,7 +20,7 @@ namespace Timeline.Tests.Helpers services.AddEntityFrameworkSqlite();
services.AddDbContext<DatabaseContext>(options =>
{
- options.UseSqlite(DatabaseConnection);
+ options.UseSqlite(Database.Connection);
});
});
});
@@ -45,8 +28,7 @@ namespace Timeline.Tests.Helpers public void Dispose()
{
- DatabaseConnection.Close();
- DatabaseConnection.Dispose();
+ Database.Dispose();
}
}
}
diff --git a/Timeline.Tests/Mock/Services/TestClock.cs b/Timeline.Tests/Helpers/TestClock.cs index 6671395a..12b320d3 100644 --- a/Timeline.Tests/Mock/Services/TestClock.cs +++ b/Timeline.Tests/Helpers/TestClock.cs @@ -1,15 +1,15 @@ -using System;
-using Timeline.Services;
-
-namespace Timeline.Tests.Mock.Services
-{
- public class TestClock : IClock
- {
- public DateTime? MockCurrentTime { get; set; } = null;
-
- public DateTime GetCurrentTime()
- {
- return MockCurrentTime.GetValueOrDefault(DateTime.Now);
- }
- }
-}
+using System; +using Timeline.Services; + +namespace Timeline.Tests.Helpers +{ + public class TestClock : IClock + { + public DateTime? MockCurrentTime { get; set; } = null; + + public DateTime GetCurrentTime() + { + return MockCurrentTime.GetValueOrDefault(DateTime.Now); + } + } +} diff --git a/Timeline.Tests/Helpers/TestDatabase.cs b/Timeline.Tests/Helpers/TestDatabase.cs new file mode 100644 index 00000000..10224c27 --- /dev/null +++ b/Timeline.Tests/Helpers/TestDatabase.cs @@ -0,0 +1,89 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Linq; +using Timeline.Entities; +using Timeline.Models; +using Timeline.Services; + +namespace Timeline.Tests.Helpers +{ + public class TestDatabase : IDisposable + { + // currently password service is thread safe, so we share a static one. + private static PasswordService PasswordService { get; } = new PasswordService(); + + private static User CreateEntityFromMock(MockUser user) + { + return new User + { + Name = user.Username, + EncryptedPassword = PasswordService.HashPassword(user.Password), + RoleString = UserRoleConvert.ToString(user.Administrator), + Avatar = null + }; + } + + private static IEnumerable<User> CreateDefaultMockEntities() + { + // emmmmmmm. Never reuse the user instances because EF Core uses them, which will cause strange things. + yield return CreateEntityFromMock(MockUser.User); + yield return CreateEntityFromMock(MockUser.Admin); + } + + private static void InitDatabase(DatabaseContext context) + { + context.Database.EnsureCreated(); + context.Users.AddRange(CreateDefaultMockEntities()); + context.SaveChanges(); + } + + public SqliteConnection Connection { get; } + public DatabaseContext Context { get; } + + public TestDatabase() + { + Connection = new SqliteConnection("Data Source=:memory:;"); + Connection.Open(); + + var options = new DbContextOptionsBuilder<DatabaseContext>() + .UseSqlite(Connection) + .Options; + + Context = new DatabaseContext(options); + + InitDatabase(Context); + } + + private List<MockUser> _extraMockUsers; + + public IReadOnlyList<MockUser> ExtraMockUsers => _extraMockUsers; + + public void CreateExtraMockUsers(int count) + { + if (count <= 0) + throw new ArgumentOutOfRangeException(nameof(count), count, "Additional user count must be bigger than 0."); + if (_extraMockUsers != null) + throw new InvalidOperationException("Already create mock users."); + + _extraMockUsers = new List<MockUser>(); + for (int i = 0; i < count; i++) + { + _extraMockUsers.Add(new MockUser($"user{i}", $"password", false)); + } + + Context.AddRange(_extraMockUsers.Select(u => CreateEntityFromMock(u))); + Context.SaveChanges(); + } + + public void Dispose() + { + Context.Dispose(); + + Connection.Close(); + Connection.Dispose(); + } + + } +} diff --git a/Timeline.Tests/Helpers/UseCultureAttribute.cs b/Timeline.Tests/Helpers/UseCultureAttribute.cs index f0064c01..017d77a8 100644 --- a/Timeline.Tests/Helpers/UseCultureAttribute.cs +++ b/Timeline.Tests/Helpers/UseCultureAttribute.cs @@ -1,91 +1,94 @@ using System; using System.Globalization; -using System.Linq; using System.Reflection; using System.Threading; using Xunit.Sdk; -// Copied from https://github.com/xunit/samples.xunit/blob/master/UseCulture/UseCultureAttribute.cs -/// <summary> -/// Apply this attribute to your test method to replace the -/// <see cref="Thread.CurrentThread" /> <see cref="CultureInfo.CurrentCulture" /> and -/// <see cref="CultureInfo.CurrentUICulture" /> with another culture. -/// </summary> -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] -public class UseCultureAttribute : BeforeAfterTestAttribute +namespace Timeline.Tests.Helpers { - readonly Lazy<CultureInfo> culture; - readonly Lazy<CultureInfo> uiCulture; - - CultureInfo originalCulture; - CultureInfo originalUICulture; + // Copied from https://github.com/xunit/samples.xunit/blob/master/UseCulture/UseCultureAttribute.cs /// <summary> - /// Replaces the culture and UI culture of the current thread with - /// <paramref name="culture" /> + /// Apply this attribute to your test method to replace the + /// <see cref="Thread.CurrentThread" /> <see cref="CultureInfo.CurrentCulture" /> and + /// <see cref="CultureInfo.CurrentUICulture" /> with another culture. /// </summary> - /// <param name="culture">The name of the culture.</param> - /// <remarks> - /// <para> - /// This constructor overload uses <paramref name="culture" /> for both - /// <see cref="Culture" /> and <see cref="UICulture" />. - /// </para> - /// </remarks> - public UseCultureAttribute(string culture) - : this(culture, culture) { } - - /// <summary> - /// Replaces the culture and UI culture of the current thread with - /// <paramref name="culture" /> and <paramref name="uiCulture" /> - /// </summary> - /// <param name="culture">The name of the culture.</param> - /// <param name="uiCulture">The name of the UI culture.</param> - public UseCultureAttribute(string culture, string uiCulture) + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public class UseCultureAttribute : BeforeAfterTestAttribute { - this.culture = new Lazy<CultureInfo>(() => new CultureInfo(culture, false)); - this.uiCulture = new Lazy<CultureInfo>(() => new CultureInfo(uiCulture, false)); - } + readonly Lazy<CultureInfo> culture; + readonly Lazy<CultureInfo> uiCulture; - /// <summary> - /// Gets the culture. - /// </summary> - public CultureInfo Culture { get { return culture.Value; } } + CultureInfo originalCulture; + CultureInfo originalUICulture; - /// <summary> - /// Gets the UI culture. - /// </summary> - public CultureInfo UICulture { get { return uiCulture.Value; } } + /// <summary> + /// Replaces the culture and UI culture of the current thread with + /// <paramref name="culture" /> + /// </summary> + /// <param name="culture">The name of the culture.</param> + /// <remarks> + /// <para> + /// This constructor overload uses <paramref name="culture" /> for both + /// <see cref="Culture" /> and <see cref="UICulture" />. + /// </para> + /// </remarks> + public UseCultureAttribute(string culture) + : this(culture, culture) { } - /// <summary> - /// Stores the current <see cref="Thread.CurrentPrincipal" /> - /// <see cref="CultureInfo.CurrentCulture" /> and <see cref="CultureInfo.CurrentUICulture" /> - /// and replaces them with the new cultures defined in the constructor. - /// </summary> - /// <param name="methodUnderTest">The method under test</param> - public override void Before(MethodInfo methodUnderTest) - { - originalCulture = Thread.CurrentThread.CurrentCulture; - originalUICulture = Thread.CurrentThread.CurrentUICulture; + /// <summary> + /// Replaces the culture and UI culture of the current thread with + /// <paramref name="culture" /> and <paramref name="uiCulture" /> + /// </summary> + /// <param name="culture">The name of the culture.</param> + /// <param name="uiCulture">The name of the UI culture.</param> + public UseCultureAttribute(string culture, string uiCulture) + { + this.culture = new Lazy<CultureInfo>(() => new CultureInfo(culture, false)); + this.uiCulture = new Lazy<CultureInfo>(() => new CultureInfo(uiCulture, false)); + } - Thread.CurrentThread.CurrentCulture = Culture; - Thread.CurrentThread.CurrentUICulture = UICulture; + /// <summary> + /// Gets the culture. + /// </summary> + public CultureInfo Culture { get { return culture.Value; } } - CultureInfo.CurrentCulture.ClearCachedData(); - CultureInfo.CurrentUICulture.ClearCachedData(); - } + /// <summary> + /// Gets the UI culture. + /// </summary> + public CultureInfo UICulture { get { return uiCulture.Value; } } - /// <summary> - /// Restores the original <see cref="CultureInfo.CurrentCulture" /> and - /// <see cref="CultureInfo.CurrentUICulture" /> to <see cref="Thread.CurrentPrincipal" /> - /// </summary> - /// <param name="methodUnderTest">The method under test</param> - public override void After(MethodInfo methodUnderTest) - { - Thread.CurrentThread.CurrentCulture = originalCulture; - Thread.CurrentThread.CurrentUICulture = originalUICulture; + /// <summary> + /// Stores the current <see cref="Thread.CurrentPrincipal" /> + /// <see cref="CultureInfo.CurrentCulture" /> and <see cref="CultureInfo.CurrentUICulture" /> + /// and replaces them with the new cultures defined in the constructor. + /// </summary> + /// <param name="methodUnderTest">The method under test</param> + public override void Before(MethodInfo methodUnderTest) + { + originalCulture = Thread.CurrentThread.CurrentCulture; + originalUICulture = Thread.CurrentThread.CurrentUICulture; + + Thread.CurrentThread.CurrentCulture = Culture; + Thread.CurrentThread.CurrentUICulture = UICulture; + + CultureInfo.CurrentCulture.ClearCachedData(); + CultureInfo.CurrentUICulture.ClearCachedData(); + } + + /// <summary> + /// Restores the original <see cref="CultureInfo.CurrentCulture" /> and + /// <see cref="CultureInfo.CurrentUICulture" /> to <see cref="Thread.CurrentPrincipal" /> + /// </summary> + /// <param name="methodUnderTest">The method under test</param> + public override void After(MethodInfo methodUnderTest) + { + Thread.CurrentThread.CurrentCulture = originalCulture; + Thread.CurrentThread.CurrentUICulture = originalUICulture; - CultureInfo.CurrentCulture.ClearCachedData(); - CultureInfo.CurrentUICulture.ClearCachedData(); + CultureInfo.CurrentCulture.ClearCachedData(); + CultureInfo.CurrentUICulture.ClearCachedData(); + } } } diff --git a/Timeline.Tests/IntegratedTests/AuthorizationTest.cs b/Timeline.Tests/IntegratedTests/AuthorizationTest.cs index a31d98f5..0bc094af 100644 --- a/Timeline.Tests/IntegratedTests/AuthorizationTest.cs +++ b/Timeline.Tests/IntegratedTests/AuthorizationTest.cs @@ -1,28 +1,17 @@ using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
-using System;
using System.Net;
using System.Threading.Tasks;
using Timeline.Tests.Helpers;
-using Timeline.Tests.Helpers.Authentication;
using Xunit;
namespace Timeline.Tests.IntegratedTests
{
- public class AuthorizationTest : IClassFixture<WebApplicationFactory<Startup>>, IDisposable
+ public class AuthorizationTest : IntegratedTestBase
{
- private readonly TestApplication _testApp;
- private readonly WebApplicationFactory<Startup> _factory;
-
public AuthorizationTest(WebApplicationFactory<Startup> factory)
+ : base(factory)
{
- _testApp = new TestApplication(factory);
- _factory = _testApp.Factory;
- }
-
- public void Dispose()
- {
- _testApp.Dispose();
}
private const string BaseUrl = "testing/auth/";
@@ -33,7 +22,7 @@ namespace Timeline.Tests.IntegratedTests [Fact]
public async Task UnauthenticationTest()
{
- using var client = _factory.CreateDefaultClient();
+ using var client = await CreateClientWithNoAuth();
var response = await client.GetAsync(AuthorizeUrl);
response.Should().HaveStatusCode(HttpStatusCode.Unauthorized);
}
@@ -41,7 +30,7 @@ namespace Timeline.Tests.IntegratedTests [Fact]
public async Task AuthenticationTest()
{
- using var client = await _factory.CreateClientAsUser();
+ using var client = await CreateClientAsUser();
var response = await client.GetAsync(AuthorizeUrl);
response.Should().HaveStatusCode(HttpStatusCode.OK);
}
@@ -49,7 +38,7 @@ namespace Timeline.Tests.IntegratedTests [Fact]
public async Task UserAuthorizationTest()
{
- using var client = await _factory.CreateClientAsUser();
+ using var client = await CreateClientAsUser();
var response1 = await client.GetAsync(UserUrl);
response1.Should().HaveStatusCode(HttpStatusCode.OK);
var response2 = await client.GetAsync(AdminUrl);
@@ -59,7 +48,7 @@ namespace Timeline.Tests.IntegratedTests [Fact]
public async Task AdminAuthorizationTest()
{
- using var client = await _factory.CreateClientAsAdmin();
+ using var client = await CreateClientAsAdmin();
var response1 = await client.GetAsync(UserUrl);
response1.Should().HaveStatusCode(HttpStatusCode.OK);
var response2 = await client.GetAsync(AdminUrl);
diff --git a/Timeline.Tests/IntegratedTests/I18nTest.cs b/Timeline.Tests/IntegratedTests/I18nTest.cs index 67bbea5c..855179af 100644 --- a/Timeline.Tests/IntegratedTests/I18nTest.cs +++ b/Timeline.Tests/IntegratedTests/I18nTest.cs @@ -1,32 +1,28 @@ -using Microsoft.AspNetCore.Mvc.Testing;
+using FluentAssertions;
+using Microsoft.AspNetCore.Mvc.Testing;
using System;
-using System.Collections.Generic;
-using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using Timeline.Tests.Helpers;
using Xunit;
-using FluentAssertions;
namespace Timeline.Tests.IntegratedTests
{
[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1054:Uri parameters should not be strings")]
- public class I18nTest : IClassFixture<WebApplicationFactory<Startup>>, IDisposable
+ public class I18nTest : IntegratedTestBase
{
- private readonly TestApplication _testApp;
private readonly HttpClient _client;
public I18nTest(WebApplicationFactory<Startup> factory)
+ : base(factory)
{
- _testApp = new TestApplication(factory);
- _client = _testApp.Factory.CreateDefaultClient();
+ _client = Factory.CreateDefaultClient();
}
- public void Dispose()
+ protected override void OnDispose()
{
_client.Dispose();
- _testApp.Dispose();
}
private const string DirectUrl = "testing/i18n/direct";
diff --git a/Timeline.Tests/IntegratedTests/IntegratedTestBase.cs b/Timeline.Tests/IntegratedTests/IntegratedTestBase.cs new file mode 100644 index 00000000..242a452d --- /dev/null +++ b/Timeline.Tests/IntegratedTests/IntegratedTestBase.cs @@ -0,0 +1,94 @@ +using Microsoft.AspNetCore.Mvc.Testing; +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using Timeline.Models.Http; +using Timeline.Tests.Helpers; +using Xunit; + +namespace Timeline.Tests.IntegratedTests +{ + public enum AuthType + { + None, + User, + Admin + } + + public static class AuthTypeExtensions + { + public static MockUser GetMockUser(this AuthType authType) + { + return authType switch + { + AuthType.None => null, + AuthType.User => MockUser.User, + AuthType.Admin => MockUser.Admin, + _ => throw new InvalidOperationException("Unknown auth type.") + }; + } + + public static string GetUsername(this AuthType authType) => authType.GetMockUser().Username; + } + + public abstract class IntegratedTestBase : IClassFixture<WebApplicationFactory<Startup>>, IDisposable + { + protected TestApplication TestApp { get; } + + protected WebApplicationFactory<Startup> Factory => TestApp.Factory; + + public IntegratedTestBase(WebApplicationFactory<Startup> factory) + { + TestApp = new TestApplication(factory); + } + + protected virtual void OnDispose() + { + + } + + public void Dispose() + { + OnDispose(); + TestApp.Dispose(); + } + + protected void CreateExtraMockUsers(int count) + { + TestApp.Database.CreateExtraMockUsers(count); + } + + protected IReadOnlyList<MockUser> ExtraMockUsers => TestApp.Database.ExtraMockUsers; + + public Task<HttpClient> CreateClientWithNoAuth() + { + return Task.FromResult(Factory.CreateDefaultClient()); + } + + public async Task<HttpClient> CreateClientWithCredential(string username, string password) + { + var client = Factory.CreateDefaultClient(); + var response = await client.PostAsJsonAsync("/token/create", + new CreateTokenRequest { Username = username, Password = password }); + var token = response.Should().HaveStatusCode(200) + .And.HaveJsonBody<CreateTokenResponse>().Which.Token; + client.DefaultRequestHeaders.Add("Authorization", "Bearer " + token); + return client; + } + + public Task<HttpClient> CreateClientAs(MockUser user) + { + if (user == null) + return CreateClientWithNoAuth(); + return CreateClientWithCredential(user.Username, user.Password); + } + + public Task<HttpClient> CreateClientAs(AuthType authType) => CreateClientAs(authType.GetMockUser()); + + + public Task<HttpClient> CreateClientAsUser() => CreateClientAs(MockUser.User); + public Task<HttpClient> CreateClientAsAdmin() => CreateClientAs(MockUser.Admin); + + } +} diff --git a/Timeline.Tests/IntegratedTests/PersonalTimelineTest.cs b/Timeline.Tests/IntegratedTests/PersonalTimelineTest.cs new file mode 100644 index 00000000..483499fb --- /dev/null +++ b/Timeline.Tests/IntegratedTests/PersonalTimelineTest.cs @@ -0,0 +1,495 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Timeline.Models; +using Timeline.Models.Http; +using Timeline.Tests.Helpers; +using Xunit; + +namespace Timeline.Tests.IntegratedTests +{ + public class PersonalTimelineTest : IntegratedTestBase + { + public PersonalTimelineTest(WebApplicationFactory<Startup> factory) + : base(factory) + { + + } + + [Fact] + public async Task TimelineGet_Should_Work() + { + using var client = await CreateClientWithNoAuth(); + var res = await client.GetAsync("users/user/timeline"); + var body = res.Should().HaveStatusCode(200) + .And.HaveJsonBody<BaseTimelineInfo>().Which; + body.Owner.Should().Be("user"); + body.Visibility.Should().Be(TimelineVisibility.Register); + body.Description.Should().Be(""); + body.Members.Should().NotBeNull().And.BeEmpty(); + } + + [Fact] + public async Task Description_Should_Work() + { + using var client = await CreateClientAsUser(); + + async Task AssertDescription(string description) + { + var res = await client.GetAsync("users/user/timeline"); + var body = res.Should().HaveStatusCode(200) + .And.HaveJsonBody<BaseTimelineInfo>() + .Which.Description.Should().Be(description); + } + + const string mockDescription = "haha"; + + await AssertDescription(""); + { + var res = await client.PostAsJsonAsync("users/user/timeline/op/property", + new TimelinePropertyChangeRequest { Description = mockDescription }); + res.Should().HaveStatusCode(200); + await AssertDescription(mockDescription); + } + { + var res = await client.PostAsJsonAsync("users/user/timeline/op/property", + new TimelinePropertyChangeRequest { Description = null }); + res.Should().HaveStatusCode(200); + await AssertDescription(mockDescription); + } + { + var res = await client.PostAsJsonAsync("users/user/timeline/op/property", + new TimelinePropertyChangeRequest { Description = "" }); + res.Should().HaveStatusCode(200); + await AssertDescription(""); + } + } + + [Fact] + public async Task Member_Should_Work() + { + const string getUrl = "users/user/timeline"; + const string changeUrl = "users/user/timeline/op/member"; + using var client = await CreateClientAsUser(); + + async Task AssertMembers(IList<string> members) + { + var res = await client.GetAsync(getUrl); + res.Should().HaveStatusCode(200) + .And.HaveJsonBody<BaseTimelineInfo>() + .Which.Members.Should().NotBeNull().And.BeEquivalentTo(members); + } + + async Task AssertEmptyMembers() + { + var res = await client.GetAsync(getUrl); + res.Should().HaveStatusCode(200) + .And.HaveJsonBody<BaseTimelineInfo>() + .Which.Members.Should().NotBeNull().And.BeEmpty(); + } + + await AssertEmptyMembers(); + { + var res = await client.PostAsJsonAsync(changeUrl, + new TimelineMemberChangeRequest { Add = new List<string> { "admin", "usernotexist" } }); + res.Should().HaveStatusCode(400) + .And.HaveCommonBody() + .Which.Code.Should().Be(ErrorCodes.Http.Timeline.ChangeMemberUserNotExist); + } + { + var res = await client.PostAsJsonAsync(changeUrl, + new TimelineMemberChangeRequest { Remove = new List<string> { "admin", "usernotexist" } }); + res.Should().HaveStatusCode(400) + .And.HaveCommonBody() + .Which.Code.Should().Be(ErrorCodes.Http.Timeline.ChangeMemberUserNotExist); + } + { + var res = await client.PostAsJsonAsync(changeUrl, + new TimelineMemberChangeRequest { Add = new List<string> { "admin" }, Remove = new List<string> { "admin" } }); + res.Should().HaveStatusCode(200); + await AssertEmptyMembers(); + } + { + var res = await client.PostAsJsonAsync(changeUrl, + new TimelineMemberChangeRequest { Add = new List<string> { "admin" } }); + res.Should().HaveStatusCode(200); + await AssertMembers(new List<string> { "admin" }); + } + { + var res = await client.PostAsJsonAsync(changeUrl, + new TimelineMemberChangeRequest { Remove = new List<string> { "admin" } }); + res.Should().HaveStatusCode(200); + await AssertEmptyMembers(); + } + } + + [Theory] + [InlineData(AuthType.None, 200, 401, 401, 401, 401)] + [InlineData(AuthType.User, 200, 200, 403, 200, 403)] + [InlineData(AuthType.Admin, 200, 200, 200, 200, 200)] + public async Task Permission_Timeline(AuthType authType, int get, int opPropertyUser, int opPropertyAdmin, int opMemberUser, int opMemberAdmin) + { + using var client = await CreateClientAs(authType); + { + var res = await client.GetAsync("users/user/timeline"); + res.Should().HaveStatusCode(get); + } + + { + var res = await client.PostAsJsonAsync("users/user/timeline/op/property", + new TimelinePropertyChangeRequest { Description = "hahaha" }); + res.Should().HaveStatusCode(opPropertyUser); + } + + { + var res = await client.PostAsJsonAsync("users/admin/timeline/op/property", + new TimelinePropertyChangeRequest { Description = "hahaha" }); + res.Should().HaveStatusCode(opPropertyAdmin); + } + + { + var res = await client.PostAsJsonAsync("users/user/timeline/op/member", + new TimelineMemberChangeRequest { Add = new List<string> { "admin" } }); + res.Should().HaveStatusCode(opMemberUser); + } + + { + var res = await client.PostAsJsonAsync("users/admin/timeline/op/member", + new TimelineMemberChangeRequest { Add = new List<string> { "user" } }); + res.Should().HaveStatusCode(opMemberAdmin); + } + } + + [Fact] + public async Task Permission_GetPost() + { + const string userUrl = "users/user/timeline/posts"; + const string adminUrl = "users/admin/timeline/posts"; + { // default visibility is registered + { + using var client = await CreateClientWithNoAuth(); + var res = await client.GetAsync(userUrl); + res.Should().HaveStatusCode(403); + } + + { + using var client = await CreateClientAsUser(); + var res = await client.GetAsync(adminUrl); + res.Should().HaveStatusCode(200); + } + } + + { // change visibility to public + { + using var client = await CreateClientAsUser(); + var res = await client.PostAsJsonAsync("users/user/timeline/op/property", + new TimelinePropertyChangeRequest { Visibility = TimelineVisibility.Public }); + res.Should().HaveStatusCode(200); + } + { + using var client = await CreateClientWithNoAuth(); + var res = await client.GetAsync(userUrl); + res.Should().HaveStatusCode(200); + } + } + + { // change visibility to private + { + using var client = await CreateClientAsAdmin(); + { + var res = await client.PostAsJsonAsync("users/user/timeline/op/property", + new TimelinePropertyChangeRequest { Visibility = TimelineVisibility.Private }); + res.Should().HaveStatusCode(200); + } + { + var res = await client.PostAsJsonAsync("users/admin/timeline/op/property", + new TimelinePropertyChangeRequest { Visibility = TimelineVisibility.Private }); + res.Should().HaveStatusCode(200); + } + } + { + using var client = await CreateClientWithNoAuth(); + var res = await client.GetAsync(userUrl); + res.Should().HaveStatusCode(403); + } + { // user can't read admin's + using var client = await CreateClientAsUser(); + var res = await client.GetAsync(adminUrl); + res.Should().HaveStatusCode(403); + } + { // admin can read user's + using var client = await CreateClientAsAdmin(); + var res = await client.GetAsync(userUrl); + res.Should().HaveStatusCode(200); + } + { // add member + using var client = await CreateClientAsAdmin(); + var res = await client.PostAsJsonAsync("users/admin/timeline/op/member", + new TimelineMemberChangeRequest { Add = new List<string> { "user" } }); + res.Should().HaveStatusCode(200); + } + { // now user can read admin's + using var client = await CreateClientAsUser(); + var res = await client.GetAsync(adminUrl); + res.Should().HaveStatusCode(200); + } + } + } + + + [Fact] + public async Task Permission_Post_Create() + { + CreateExtraMockUsers(1); + + using (var client = await CreateClientAsUser()) + { + var res = await client.PostAsJsonAsync("users/user/timeline/op/member", + new TimelineMemberChangeRequest { Add = new List<string> { "user0" } }); + res.Should().HaveStatusCode(200); + } + + using (var client = await CreateClientWithNoAuth()) + { + { // no auth should get 401 + var res = await client.PostAsJsonAsync("users/user/timeline/postop/create", + new TimelinePostCreateRequest { Content = "aaa" }); + res.Should().HaveStatusCode(401); + } + } + + using (var client = await CreateClientAsUser()) + { + { // post self's + var res = await client.PostAsJsonAsync("users/user/timeline/postop/create", + new TimelinePostCreateRequest { Content = "aaa" }); + res.Should().HaveStatusCode(200); + } + { // post other not as a member should get 403 + var res = await client.PostAsJsonAsync("users/admin/timeline/postop/create", + new TimelinePostCreateRequest { Content = "aaa" }); + res.Should().HaveStatusCode(403); + } + } + + using (var client = await CreateClientAsAdmin()) + { + { // post as admin + var res = await client.PostAsJsonAsync("users/user/timeline/postop/create", + new TimelinePostCreateRequest { Content = "aaa" }); + res.Should().HaveStatusCode(200); + } + } + + using (var client = await CreateClientAs(ExtraMockUsers[0])) + { + { // post as member + var res = await client.PostAsJsonAsync("users/user/timeline/postop/create", + new TimelinePostCreateRequest { Content = "aaa" }); + res.Should().HaveStatusCode(200); + } + } + } + + [Fact] + public async Task Permission_Post_Delete() + { + CreateExtraMockUsers(2); + + async Task<long> CreatePost(MockUser auth, string timeline) + { + using var client = await CreateClientAs(auth); + var res = await client.PostAsJsonAsync($"users/{timeline}/timeline/postop/create", + new TimelinePostCreateRequest { Content = "aaa" }); + return res.Should().HaveStatusCode(200) + .And.HaveJsonBody<TimelinePostCreateResponse>() + .Which.Id; + } + + using (var client = await CreateClientAsUser()) + { + var res = await client.PostAsJsonAsync("users/user/timeline/op/member", + new TimelineMemberChangeRequest { Add = new List<string> { "user0", "user1" } }); + res.Should().HaveStatusCode(200); + } + + { // no auth should get 401 + using var client = await CreateClientWithNoAuth(); + var res = await client.PostAsJsonAsync("users/user/timeline/postop/delete", + new TimelinePostDeleteRequest { Id = 12 }); + res.Should().HaveStatusCode(401); + } + + { // self can delete self + var postId = await CreatePost(MockUser.User, "user"); + using var client = await CreateClientAsUser(); + var res = await client.PostAsJsonAsync("users/user/timeline/postop/delete", + new TimelinePostDeleteRequest { Id = postId }); + res.Should().HaveStatusCode(200); + } + + { // admin can delete any + var postId = await CreatePost(MockUser.User, "user"); + using var client = await CreateClientAsAdmin(); + var res = await client.PostAsJsonAsync("users/user/timeline/postop/delete", + new TimelinePostDeleteRequest { Id = postId }); + res.Should().HaveStatusCode(200); + } + + { // owner can delete other + var postId = await CreatePost(ExtraMockUsers[0], "user"); + using var client = await CreateClientAsUser(); + var res = await client.PostAsJsonAsync("users/user/timeline/postop/delete", + new TimelinePostDeleteRequest { Id = postId }); + res.Should().HaveStatusCode(200); + } + + { // author can delete self + var postId = await CreatePost(ExtraMockUsers[0], "user"); + using var client = await CreateClientAs(ExtraMockUsers[0]); + var res = await client.PostAsJsonAsync("users/user/timeline/postop/delete", + new TimelinePostDeleteRequest { Id = postId }); + res.Should().HaveStatusCode(200); + } + + { // otherwise is forbidden + var postId = await CreatePost(ExtraMockUsers[0], "user"); + using var client = await CreateClientAs(ExtraMockUsers[1]); + var res = await client.PostAsJsonAsync("users/user/timeline/postop/delete", + new TimelinePostDeleteRequest { Id = postId }); + res.Should().HaveStatusCode(403); + } + } + + [Fact] + public async Task Post_Op_Should_Work() + { + { + using var client = await CreateClientAsUser(); + { + var res = await client.GetAsync("users/user/timeline/posts"); + res.Should().HaveStatusCode(200) + .And.HaveJsonBody<TimelinePostInfo[]>() + .Which.Should().NotBeNull().And.BeEmpty(); + } + { + var res = await client.PostAsJsonAsync("users/user/timeline/postop/create", + new TimelinePostCreateRequest { Content = null }); + res.Should().BeInvalidModel(); + } + const string mockContent = "aaa"; + TimelinePostCreateResponse createRes; + { + var res = await client.PostAsJsonAsync("users/user/timeline/postop/create", + new TimelinePostCreateRequest { Content = mockContent }); + var body = res.Should().HaveStatusCode(200) + .And.HaveJsonBody<TimelinePostCreateResponse>() + .Which; + body.Should().NotBeNull(); + createRes = body; + } + { + var res = await client.GetAsync("users/user/timeline/posts"); + res.Should().HaveStatusCode(200) + .And.HaveJsonBody<TimelinePostInfo[]>() + .Which.Should().NotBeNull().And.BeEquivalentTo( + new TimelinePostInfo + { + Id = createRes.Id, + Author = "user", + Content = mockContent, + Time = createRes.Time + }); + } + const string mockContent2 = "bbb"; + var mockTime2 = DateTime.Now.AddDays(-1); + TimelinePostCreateResponse createRes2; + { + var res = await client.PostAsJsonAsync("users/user/timeline/postop/create", + new TimelinePostCreateRequest { Content = mockContent2, Time = mockTime2 }); + var body = res.Should().HaveStatusCode(200) + .And.HaveJsonBody<TimelinePostCreateResponse>() + .Which; + body.Should().NotBeNull(); + createRes2 = body; + } + { + var res = await client.GetAsync("users/user/timeline/posts"); + res.Should().HaveStatusCode(200) + .And.HaveJsonBody<TimelinePostInfo[]>() + .Which.Should().NotBeNull().And.BeEquivalentTo( + new TimelinePostInfo + { + Id = createRes.Id, + Author = "user", + Content = mockContent, + Time = createRes.Time + }, + new TimelinePostInfo + { + Id = createRes2.Id, + Author = "user", + Content = mockContent2, + Time = createRes2.Time + }); + } + { + var res = await client.PostAsJsonAsync("users/user/timeline/postop/delete", + new TimelinePostDeleteRequest { Id = createRes.Id }); + res.Should().HaveStatusCode(200); + } + { + var res = await client.PostAsJsonAsync("users/user/timeline/postop/delete", + new TimelinePostDeleteRequest { Id = 30000 }); + res.Should().HaveStatusCode(400) + .And.HaveCommonBody() + .Which.Code.Should().Be(ErrorCodes.Http.Timeline.PostOperationDeleteNotExist); + } + { + var res = await client.GetAsync("users/user/timeline/posts"); + res.Should().HaveStatusCode(200) + .And.HaveJsonBody<TimelinePostInfo[]>() + .Which.Should().NotBeNull().And.BeEquivalentTo( + new TimelinePostInfo + { + Id = createRes2.Id, + Author = "user", + Content = mockContent2, + Time = createRes2.Time + }); + } + } + } + + [Fact] + public async Task GetPost_Should_Ordered() + { + using var client = await CreateClientAsUser(); + + async Task<long> CreatePost(DateTime time) + { + var res = await client.PostAsJsonAsync("users/user/timeline/postop/create", + new TimelinePostCreateRequest { Content = "aaa", Time = time }); + return res.Should().HaveStatusCode(200) + .And.HaveJsonBody<TimelinePostCreateResponse>() + .Which.Id; + } + + var now = DateTime.Now; + var id0 = await CreatePost(now.AddDays(1)); + var id1 = await CreatePost(now.AddDays(-1)); + var id2 = await CreatePost(now); + + { + var res = await client.GetAsync("users/user/timeline/posts"); + res.Should().HaveStatusCode(200) + .And.HaveJsonBody<TimelinePostInfo[]>() + .Which.Select(p => p.Id).Should().Equal(id1, id2, id0); + } + } + } +} diff --git a/Timeline.Tests/IntegratedTests/TokenTest.cs b/Timeline.Tests/IntegratedTests/TokenTest.cs index 111e8d8e..e62228fc 100644 --- a/Timeline.Tests/IntegratedTests/TokenTest.cs +++ b/Timeline.Tests/IntegratedTests/TokenTest.cs @@ -1,37 +1,33 @@ using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
-using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
using Timeline.Models.Http;
using Timeline.Services;
using Timeline.Tests.Helpers;
-using Timeline.Tests.Helpers.Authentication;
-using Timeline.Tests.Mock.Data;
using Xunit;
using static Timeline.ErrorCodes.Http.Token;
namespace Timeline.Tests.IntegratedTests
{
- public class TokenTest : IClassFixture<WebApplicationFactory<Startup>>, IDisposable
+ public class TokenTest : IntegratedTestBase
{
private const string CreateTokenUrl = "token/create";
private const string VerifyTokenUrl = "token/verify";
- private readonly TestApplication _testApp;
- private readonly WebApplicationFactory<Startup> _factory;
-
public TokenTest(WebApplicationFactory<Startup> factory)
+ : base(factory)
{
- _testApp = new TestApplication(factory);
- _factory = _testApp.Factory;
+
}
- public void Dispose()
+ private static async Task<CreateTokenResponse> CreateUserTokenAsync(HttpClient client, string username, string password, int? expireOffset = null)
{
- _testApp.Dispose();
+ var response = await client.PostAsJsonAsync(CreateTokenUrl, new CreateTokenRequest { Username = username, Password = password, Expire = expireOffset });
+ return response.Should().HaveStatusCode(200)
+ .And.HaveJsonBody<CreateTokenResponse>().Which;
}
public static IEnumerable<object[]> CreateToken_InvalidModel_Data()
@@ -46,7 +42,7 @@ namespace Timeline.Tests.IntegratedTests [MemberData(nameof(CreateToken_InvalidModel_Data))]
public async Task CreateToken_InvalidModel(string username, string password, int expire)
{
- using var client = _factory.CreateDefaultClient();
+ using var client = await CreateClientWithNoAuth();
(await client.PostAsJsonAsync(CreateTokenUrl, new CreateTokenRequest
{
Username = username,
@@ -65,7 +61,7 @@ namespace Timeline.Tests.IntegratedTests [MemberData(nameof(CreateToken_UserCredential_Data))]
public async void CreateToken_UserCredential(string username, string password)
{
- using var client = _factory.CreateDefaultClient();
+ using var client = await CreateClientWithNoAuth();
var response = await client.PostAsJsonAsync(CreateTokenUrl,
new CreateTokenRequest { Username = username, Password = password });
response.Should().HaveStatusCode(400)
@@ -76,7 +72,7 @@ namespace Timeline.Tests.IntegratedTests [Fact]
public async Task CreateToken_Success()
{
- using var client = _factory.CreateDefaultClient();
+ using var client = await CreateClientWithNoAuth();
var response = await client.PostAsJsonAsync(CreateTokenUrl,
new CreateTokenRequest { Username = MockUser.User.Username, Password = MockUser.User.Password });
var body = response.Should().HaveStatusCode(200)
@@ -88,7 +84,7 @@ namespace Timeline.Tests.IntegratedTests [Fact]
public async Task VerifyToken_InvalidModel()
{
- using var client = _factory.CreateDefaultClient();
+ using var client = await CreateClientWithNoAuth();
(await client.PostAsJsonAsync(VerifyTokenUrl,
new VerifyTokenRequest { Token = null })).Should().BeInvalidModel();
}
@@ -96,7 +92,7 @@ namespace Timeline.Tests.IntegratedTests [Fact]
public async Task VerifyToken_BadFormat()
{
- using var client = _factory.CreateDefaultClient();
+ using var client = await CreateClientWithNoAuth();
var response = await client.PostAsJsonAsync(VerifyTokenUrl,
new VerifyTokenRequest { Token = "bad token hahaha" });
response.Should().HaveStatusCode(400)
@@ -107,10 +103,10 @@ namespace Timeline.Tests.IntegratedTests [Fact]
public async Task VerifyToken_OldVersion()
{
- using var client = _factory.CreateDefaultClient();
- var token = (await client.CreateUserTokenAsync(MockUser.User.Username, MockUser.User.Password)).Token;
+ using var client = await CreateClientWithNoAuth();
+ var token = (await CreateUserTokenAsync(client, MockUser.User.Username, MockUser.User.Password)).Token;
- using (var scope = _factory.Server.Host.Services.CreateScope()) // UserService is scoped.
+ using (var scope = Factory.Server.Host.Services.CreateScope()) // UserService is scoped.
{
// create a user for test
var userService = scope.ServiceProvider.GetRequiredService<IUserService>();
@@ -127,10 +123,10 @@ namespace Timeline.Tests.IntegratedTests [Fact]
public async Task VerifyToken_UserNotExist()
{
- using var client = _factory.CreateDefaultClient();
- var token = (await client.CreateUserTokenAsync(MockUser.User.Username, MockUser.User.Password)).Token;
+ using var client = await CreateClientWithNoAuth();
+ var token = (await CreateUserTokenAsync(client, MockUser.User.Username, MockUser.User.Password)).Token;
- using (var scope = _factory.Server.Host.Services.CreateScope()) // UserService is scoped.
+ using (var scope = Factory.Server.Host.Services.CreateScope()) // UserService is scoped.
{
var userService = scope.ServiceProvider.GetRequiredService<IUserService>();
await userService.DeleteUser(MockUser.User.Username);
@@ -146,7 +142,7 @@ namespace Timeline.Tests.IntegratedTests //[Fact]
//public async Task VerifyToken_Expired()
//{
- // using (var client = _factory.CreateDefaultClient())
+ // using (var client = await CreateClientWithNoAuth())
// {
// // I can only control the token expired time but not current time
// // because verify logic is encapsuled in other library.
@@ -164,8 +160,8 @@ namespace Timeline.Tests.IntegratedTests [Fact]
public async Task VerifyToken_Success()
{
- using var client = _factory.CreateDefaultClient();
- var createTokenResult = await client.CreateUserTokenAsync(MockUser.User.Username, MockUser.User.Password);
+ using var client = await CreateClientWithNoAuth();
+ var createTokenResult = await CreateUserTokenAsync(client, MockUser.User.Username, MockUser.User.Password);
var response = await client.PostAsJsonAsync(VerifyTokenUrl,
new VerifyTokenRequest { Token = createTokenResult.Token });
response.Should().HaveStatusCode(200)
diff --git a/Timeline.Tests/IntegratedTests/UserAvatarTest.cs b/Timeline.Tests/IntegratedTests/UserAvatarTest.cs index 2310fc66..25a7b675 100644 --- a/Timeline.Tests/IntegratedTests/UserAvatarTest.cs +++ b/Timeline.Tests/IntegratedTests/UserAvatarTest.cs @@ -15,27 +15,18 @@ using System.Net.Http.Headers; using System.Threading.Tasks;
using Timeline.Services;
using Timeline.Tests.Helpers;
-using Timeline.Tests.Helpers.Authentication;
using Xunit;
using static Timeline.ErrorCodes.Http.Common;
using static Timeline.ErrorCodes.Http.UserAvatar;
namespace Timeline.Tests.IntegratedTests
{
- public class UserAvatarTest : IClassFixture<WebApplicationFactory<Startup>>, IDisposable
+ public class UserAvatarTest : IntegratedTestBase
{
- private readonly TestApplication _testApp;
- private readonly WebApplicationFactory<Startup> _factory;
-
public UserAvatarTest(WebApplicationFactory<Startup> factory)
+ : base(factory)
{
- _testApp = new TestApplication(factory);
- _factory = _testApp.Factory;
- }
- public void Dispose()
- {
- _testApp.Dispose();
}
[Fact]
@@ -48,7 +39,7 @@ namespace Timeline.Tests.IntegratedTests Type = PngFormat.Instance.DefaultMimeType
};
- using (var client = await _factory.CreateClientAsUser())
+ using (var client = await CreateClientAsUser())
{
{
var res = await client.GetAsync("users/usernotexist/avatar");
@@ -57,7 +48,7 @@ namespace Timeline.Tests.IntegratedTests .Which.Code.Should().Be(Get.UserNotExist);
}
- var env = _factory.Server.Host.Services.GetRequiredService<IWebHostEnvironment>();
+ var env = Factory.Server.Host.Services.GetRequiredService<IWebHostEnvironment>();
var defaultAvatarData = await File.ReadAllBytesAsync(Path.Combine(env.ContentRootPath, "default-avatar.png"));
async Task GetReturnDefault(string username = "user")
@@ -239,7 +230,7 @@ namespace Timeline.Tests.IntegratedTests }
// Authorization check.
- using (var client = await _factory.CreateClientAsAdmin())
+ using (var client = await CreateClientAsAdmin())
{
{
var res = await client.PutByteArrayAsync("users/user/avatar", mockAvatar.Data, mockAvatar.Type);
@@ -266,7 +257,7 @@ namespace Timeline.Tests.IntegratedTests }
// bad username check
- using (var client = await _factory.CreateClientAsAdmin())
+ using (var client = await CreateClientAsAdmin())
{
{
var res = await client.GetAsync("users/u!ser/avatar");
diff --git a/Timeline.Tests/IntegratedTests/UserDetailTest.cs b/Timeline.Tests/IntegratedTests/UserDetailTest.cs index 8f2b6925..932c287e 100644 --- a/Timeline.Tests/IntegratedTests/UserDetailTest.cs +++ b/Timeline.Tests/IntegratedTests/UserDetailTest.cs @@ -1,38 +1,27 @@ using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
-using System;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Mime;
using System.Threading.Tasks;
using Timeline.Tests.Helpers;
-using Timeline.Tests.Helpers.Authentication;
-using Timeline.Tests.Mock.Data;
using Xunit;
namespace Timeline.Tests.IntegratedTests
{
- public class UserDetailTest : IClassFixture<WebApplicationFactory<Startup>>, IDisposable
+ public class UserDetailTest : IntegratedTestBase
{
- private readonly TestApplication _testApp;
- private readonly WebApplicationFactory<Startup> _factory;
-
public UserDetailTest(WebApplicationFactory<Startup> factory)
+ : base(factory)
{
- _testApp = new TestApplication(factory);
- _factory = _testApp.Factory;
- }
- public void Dispose()
- {
- _testApp.Dispose();
}
[Fact]
public async Task PermissionTest()
{
{ // unauthorize
- using var client = _factory.CreateDefaultClient();
+ using var client = await CreateClientWithNoAuth();
{ // GET
var res = await client.GetAsync($"users/{MockUser.User.Username}/nickname");
res.Should().HaveStatusCode(HttpStatusCode.OK);
@@ -47,7 +36,7 @@ namespace Timeline.Tests.IntegratedTests }
}
{ // user
- using var client = await _factory.CreateClientAsUser();
+ using var client = await CreateClientAsUser();
{ // GET
var res = await client.GetAsync($"users/{MockUser.User.Username}/nickname");
res.Should().HaveStatusCode(HttpStatusCode.OK);
@@ -70,7 +59,7 @@ namespace Timeline.Tests.IntegratedTests }
}
{ // user
- using var client = await _factory.CreateClientAsAdmin();
+ using var client = await CreateClientAsAdmin();
{ // PUT other
var res = await client.PutStringAsync($"users/{MockUser.User.Username}/nickname", "aaa");
res.Should().HaveStatusCode(HttpStatusCode.OK);
@@ -88,7 +77,7 @@ namespace Timeline.Tests.IntegratedTests var url = $"users/{MockUser.User.Username}/nickname";
var userNotExistUrl = "users/usernotexist/nickname";
{
- using var client = await _factory.CreateClientAsUser();
+ using var client = await CreateClientAsUser();
{
var res = await client.GetAsync(userNotExistUrl);
res.Should().HaveStatusCode(HttpStatusCode.NotFound)
@@ -134,7 +123,7 @@ namespace Timeline.Tests.IntegratedTests }
}
{
- using var client = await _factory.CreateClientAsAdmin();
+ using var client = await CreateClientAsAdmin();
{
var res = await client.PutStringAsync(userNotExistUrl, "aaa");
res.Should().HaveStatusCode(HttpStatusCode.BadRequest)
diff --git a/Timeline.Tests/IntegratedTests/UserTest.cs b/Timeline.Tests/IntegratedTests/UserTest.cs index 7e99ddba..abfea18e 100644 --- a/Timeline.Tests/IntegratedTests/UserTest.cs +++ b/Timeline.Tests/IntegratedTests/UserTest.cs @@ -1,39 +1,28 @@ using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
-using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
using Timeline.Models;
using Timeline.Models.Http;
using Timeline.Tests.Helpers;
-using Timeline.Tests.Helpers.Authentication;
-using Timeline.Tests.Mock.Data;
using Xunit;
using static Timeline.ErrorCodes.Http.User;
namespace Timeline.Tests.IntegratedTests
{
- public class UserTest : IClassFixture<WebApplicationFactory<Startup>>, IDisposable
+ public class UserTest : IntegratedTestBase
{
- private readonly TestApplication _testApp;
- private readonly WebApplicationFactory<Startup> _factory;
-
public UserTest(WebApplicationFactory<Startup> factory)
+ : base(factory)
{
- _testApp = new TestApplication(factory);
- _factory = _testApp.Factory;
- }
- public void Dispose()
- {
- _testApp.Dispose();
}
[Fact]
public async Task Get_List_Success()
{
- using var client = await _factory.CreateClientAsAdmin();
+ using var client = await CreateClientAsAdmin();
var res = await client.GetAsync("users");
res.Should().HaveStatusCode(200)
.And.HaveJsonBody<UserInfo[]>()
@@ -43,7 +32,7 @@ namespace Timeline.Tests.IntegratedTests [Fact]
public async Task Get_Single_Success()
{
- using var client = await _factory.CreateClientAsAdmin();
+ using var client = await CreateClientAsAdmin();
var res = await client.GetAsync("users/" + MockUser.User.Username);
res.Should().HaveStatusCode(200)
.And.HaveJsonBody<UserInfo>()
@@ -53,7 +42,7 @@ namespace Timeline.Tests.IntegratedTests [Fact]
public async Task Get_InvalidModel()
{
- using var client = await _factory.CreateClientAsAdmin();
+ using var client = await CreateClientAsAdmin();
var res = await client.GetAsync("users/aaa!a");
res.Should().BeInvalidModel();
}
@@ -61,7 +50,7 @@ namespace Timeline.Tests.IntegratedTests [Fact]
public async Task Get_Users_404()
{
- using var client = await _factory.CreateClientAsAdmin();
+ using var client = await CreateClientAsAdmin();
var res = await client.GetAsync("users/usernotexist");
res.Should().HaveStatusCode(404)
.And.HaveCommonBody()
@@ -79,7 +68,7 @@ namespace Timeline.Tests.IntegratedTests [MemberData(nameof(Put_InvalidModel_Data))]
public async Task Put_InvalidModel(string username, string password, bool? administrator)
{
- using var client = await _factory.CreateClientAsAdmin();
+ using var client = await CreateClientAsAdmin();
(await client.PutAsJsonAsync("users/" + username,
new UserPutRequest { Password = password, Administrator = administrator }))
.Should().BeInvalidModel();
@@ -96,7 +85,7 @@ namespace Timeline.Tests.IntegratedTests [Fact]
public async Task Put_Modiefied()
{
- using var client = await _factory.CreateClientAsAdmin();
+ using var client = await CreateClientAsAdmin();
var res = await client.PutAsJsonAsync("users/" + MockUser.User.Username, new UserPutRequest
{
Password = "password",
@@ -109,7 +98,7 @@ namespace Timeline.Tests.IntegratedTests [Fact]
public async Task Put_Created()
{
- using var client = await _factory.CreateClientAsAdmin();
+ using var client = await CreateClientAsAdmin();
const string username = "puttest";
const string url = "users/" + username;
@@ -125,7 +114,7 @@ namespace Timeline.Tests.IntegratedTests [Fact]
public async Task Patch_NotExist()
{
- using var client = await _factory.CreateClientAsAdmin();
+ using var client = await CreateClientAsAdmin();
var res = await client.PatchAsJsonAsync("users/usernotexist", new UserPatchRequest { });
res.Should().HaveStatusCode(404)
.And.HaveCommonBody()
@@ -135,7 +124,7 @@ namespace Timeline.Tests.IntegratedTests [Fact]
public async Task Patch_InvalidModel()
{
- using var client = await _factory.CreateClientAsAdmin();
+ using var client = await CreateClientAsAdmin();
var res = await client.PatchAsJsonAsync("users/aaa!a", new UserPatchRequest { });
res.Should().BeInvalidModel();
}
@@ -143,7 +132,7 @@ namespace Timeline.Tests.IntegratedTests [Fact]
public async Task Patch_Success()
{
- using var client = await _factory.CreateClientAsAdmin();
+ using var client = await CreateClientAsAdmin();
{
var res = await client.PatchAsJsonAsync("users/" + MockUser.User.Username,
new UserPatchRequest { Administrator = false });
@@ -155,7 +144,7 @@ namespace Timeline.Tests.IntegratedTests [Fact]
public async Task Delete_InvalidModel()
{
- using var client = await _factory.CreateClientAsAdmin();
+ using var client = await CreateClientAsAdmin();
var url = "users/aaa!a";
var res = await client.DeleteAsync(url);
res.Should().BeInvalidModel();
@@ -164,7 +153,7 @@ namespace Timeline.Tests.IntegratedTests [Fact]
public async Task Delete_Deleted()
{
- using var client = await _factory.CreateClientAsAdmin();
+ using var client = await CreateClientAsAdmin();
var url = "users/" + MockUser.User.Username;
var res = await client.DeleteAsync(url);
res.Should().BeDelete(true);
@@ -176,7 +165,7 @@ namespace Timeline.Tests.IntegratedTests [Fact]
public async Task Delete_NotExist()
{
- using var client = await _factory.CreateClientAsAdmin();
+ using var client = await CreateClientAsAdmin();
var res = await client.DeleteAsync("users/usernotexist");
res.Should().BeDelete(false);
}
@@ -195,7 +184,7 @@ namespace Timeline.Tests.IntegratedTests [MemberData(nameof(Op_ChangeUsername_InvalidModel_Data))]
public async Task Op_ChangeUsername_InvalidModel(string oldUsername, string newUsername)
{
- using var client = await _factory.CreateClientAsAdmin();
+ using var client = await CreateClientAsAdmin();
(await client.PostAsJsonAsync(changeUsernameUrl,
new ChangeUsernameRequest { OldUsername = oldUsername, NewUsername = newUsername }))
.Should().BeInvalidModel();
@@ -204,7 +193,7 @@ namespace Timeline.Tests.IntegratedTests [Fact]
public async Task Op_ChangeUsername_UserNotExist()
{
- using var client = await _factory.CreateClientAsAdmin();
+ using var client = await CreateClientAsAdmin();
var res = await client.PostAsJsonAsync(changeUsernameUrl,
new ChangeUsernameRequest { OldUsername = "usernotexist", NewUsername = "newUsername" });
res.Should().HaveStatusCode(400)
@@ -215,7 +204,7 @@ namespace Timeline.Tests.IntegratedTests [Fact]
public async Task Op_ChangeUsername_UserAlreadyExist()
{
- using var client = await _factory.CreateClientAsAdmin();
+ using var client = await CreateClientAsAdmin();
var res = await client.PostAsJsonAsync(changeUsernameUrl,
new ChangeUsernameRequest { OldUsername = MockUser.User.Username, NewUsername = MockUser.Admin.Username });
res.Should().HaveStatusCode(400)
@@ -223,15 +212,23 @@ namespace Timeline.Tests.IntegratedTests .Which.Code.Should().Be(Op.ChangeUsername.AlreadyExist);
}
+ private async Task TestLogin(string username, string password)
+ {
+ using var client = await CreateClientWithNoAuth();
+ var response = await client.PostAsJsonAsync("token/create", new CreateTokenRequest { Username = username, Password = password });
+ response.Should().HaveStatusCode(200)
+ .And.HaveJsonBody<CreateTokenResponse>();
+ }
+
[Fact]
public async Task Op_ChangeUsername_Success()
{
- using var client = await _factory.CreateClientAsAdmin();
+ using var client = await CreateClientAsAdmin();
const string newUsername = "hahaha";
var res = await client.PostAsJsonAsync(changeUsernameUrl,
new ChangeUsernameRequest { OldUsername = MockUser.User.Username, NewUsername = newUsername });
res.Should().HaveStatusCode(200);
- await client.CreateUserTokenAsync(newUsername, MockUser.User.Password);
+ await TestLogin(newUsername, MockUser.User.Password);
}
private const string changePasswordUrl = "userop/changepassword";
@@ -246,7 +243,7 @@ namespace Timeline.Tests.IntegratedTests [MemberData(nameof(Op_ChangePassword_InvalidModel_Data))]
public async Task Op_ChangePassword_InvalidModel(string oldPassword, string newPassword)
{
- using var client = await _factory.CreateClientAsUser();
+ using var client = await CreateClientAsUser();
(await client.PostAsJsonAsync(changePasswordUrl,
new ChangePasswordRequest { OldPassword = oldPassword, NewPassword = newPassword }))
.Should().BeInvalidModel();
@@ -255,7 +252,7 @@ namespace Timeline.Tests.IntegratedTests [Fact]
public async Task Op_ChangePassword_BadOldPassword()
{
- using var client = await _factory.CreateClientAsUser();
+ using var client = await CreateClientAsUser();
var res = await client.PostAsJsonAsync(changePasswordUrl, new ChangePasswordRequest { OldPassword = "???", NewPassword = "???" });
res.Should().HaveStatusCode(400)
.And.HaveCommonBody()
@@ -265,13 +262,12 @@ namespace Timeline.Tests.IntegratedTests [Fact]
public async Task Op_ChangePassword_Success()
{
- using var client = await _factory.CreateClientAsUser();
+ using var client = await CreateClientAsUser();
const string newPassword = "new";
var res = await client.PostAsJsonAsync(changePasswordUrl,
new ChangePasswordRequest { OldPassword = MockUser.User.Password, NewPassword = newPassword });
res.Should().HaveStatusCode(200);
- await _factory.CreateDefaultClient() // don't use client above, because it sets authorization header
- .CreateUserTokenAsync(MockUser.User.Username, newPassword);
+ await TestLogin(MockUser.User.Username, newPassword);
}
}
}
diff --git a/Timeline.Tests/Mock/Data/TestDatabase.cs b/Timeline.Tests/Mock/Data/TestDatabase.cs deleted file mode 100644 index 1e662546..00000000 --- a/Timeline.Tests/Mock/Data/TestDatabase.cs +++ /dev/null @@ -1,42 +0,0 @@ -using Microsoft.Data.Sqlite;
-using Microsoft.EntityFrameworkCore;
-using System;
-using Timeline.Entities;
-
-namespace Timeline.Tests.Mock.Data
-{
- public class TestDatabase : IDisposable
- {
- public static void InitDatabase(DatabaseContext context)
- {
- context.Database.EnsureCreated();
- context.Users.AddRange(MockUser.CreateMockEntities());
- context.SaveChanges();
- }
-
- public TestDatabase()
- {
- DatabaseConnection = new SqliteConnection("Data Source=:memory:;");
- DatabaseConnection.Open();
-
- var options = new DbContextOptionsBuilder<DatabaseContext>()
- .UseSqlite(DatabaseConnection)
- .Options;
-
- DatabaseContext = new DatabaseContext(options);
-
- InitDatabase(DatabaseContext);
- }
-
- public void Dispose()
- {
- DatabaseContext.Dispose();
-
- DatabaseConnection.Close();
- DatabaseConnection.Dispose();
- }
-
- public SqliteConnection DatabaseConnection { get; }
- public DatabaseContext DatabaseContext { get; }
- }
-}
diff --git a/Timeline.Tests/Mock/Data/TestUsers.cs b/Timeline.Tests/Mock/Data/TestUsers.cs deleted file mode 100644 index fa75236a..00000000 --- a/Timeline.Tests/Mock/Data/TestUsers.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System;
-using System.Collections.Generic;
-using Timeline.Entities;
-using Timeline.Models;
-using Timeline.Services;
-
-namespace Timeline.Tests.Mock.Data
-{
- public class MockUser
- {
- public MockUser(string username, string password, bool administrator)
- {
- Info = new UserInfo(username, administrator);
- Password = password;
- }
-
- public UserInfo Info { get; set; }
- public string Username => Info.Username;
- public string Password { get; set; }
- public bool Administrator => Info.Administrator;
-
-
- public static MockUser User { get; } = new MockUser("user", "userpassword", false);
- public static MockUser Admin { get; } = new MockUser("admin", "adminpassword", true);
-
- public static IReadOnlyList<UserInfo> UserInfoList { get; } = new List<UserInfo> { User.Info, Admin.Info };
-
- // emmmmmmm. Never reuse the user instances because EF Core uses them, which will cause strange things.
- public static IEnumerable<User> CreateMockEntities()
- {
- var passwordService = new PasswordService();
- User Create(MockUser user)
- {
- return new User
- {
- Name = user.Username,
- EncryptedPassword = passwordService.HashPassword(user.Password),
- RoleString = UserRoleConvert.ToString(user.Administrator),
- Avatar = null
- };
- }
-
- return new List<User>
- {
- Create(User),
- Create(Admin)
- };
- }
- }
-}
diff --git a/Timeline.Tests/Services/UserAvatarServiceTest.cs b/Timeline.Tests/Services/UserAvatarServiceTest.cs index cf3d2a0a..2729aa6f 100644 --- a/Timeline.Tests/Services/UserAvatarServiceTest.cs +++ b/Timeline.Tests/Services/UserAvatarServiceTest.cs @@ -11,7 +11,6 @@ using System.Threading.Tasks; using Timeline.Entities;
using Timeline.Services;
using Timeline.Tests.Helpers;
-using Timeline.Tests.Mock.Data;
using Xunit;
namespace Timeline.Tests.Services
@@ -139,7 +138,7 @@ namespace Timeline.Tests.Services _database = new TestDatabase();
- _service = new UserAvatarService(NullLogger<UserAvatarService>.Instance, _database.DatabaseContext, _mockDefaultAvatarProvider.Object, _mockValidator.Object, _mockETagGenerator.Object, _mockClock.Object);
+ _service = new UserAvatarService(NullLogger<UserAvatarService>.Instance, _database.Context, _mockDefaultAvatarProvider.Object, _mockValidator.Object, _mockETagGenerator.Object, _mockClock.Object);
}
public void Dispose()
@@ -171,7 +170,7 @@ namespace Timeline.Tests.Services string username = MockUser.User.Username;
var mockAvatarEntity = CreateMockAvatarEntity("aaa");
{
- var context = _database.DatabaseContext;
+ var context = _database.Context;
var user = await context.Users.Where(u => u.Name == username).Include(u => u.Avatar).SingleAsync();
user.Avatar = mockAvatarEntity;
await context.SaveChangesAsync();
@@ -205,7 +204,7 @@ namespace Timeline.Tests.Services string username = MockUser.User.Username;
var mockAvatarEntity = CreateMockAvatarEntity("aaa");
{
- var context = _database.DatabaseContext;
+ var context = _database.Context;
var user = await context.Users.Where(u => u.Name == username).Include(u => u.Avatar).SingleAsync();
user.Avatar = mockAvatarEntity;
await context.SaveChangesAsync();
@@ -237,7 +236,7 @@ namespace Timeline.Tests.Services {
string username = MockUser.User.Username;
- var user = await _database.DatabaseContext.Users.Where(u => u.Name == username).Include(u => u.Avatar).SingleAsync();
+ var user = await _database.Context.Users.Where(u => u.Name == username).Include(u => u.Avatar).SingleAsync();
var avatar1 = CreateMockAvatar("aaa");
var avatar2 = CreateMockAvatar("bbb");
diff --git a/Timeline.Tests/Services/UserDetailServiceTest.cs b/Timeline.Tests/Services/UserDetailServiceTest.cs index c7037c6e..9a869c89 100644 --- a/Timeline.Tests/Services/UserDetailServiceTest.cs +++ b/Timeline.Tests/Services/UserDetailServiceTest.cs @@ -7,7 +7,6 @@ using System.Threading.Tasks; using Timeline.Entities;
using Timeline.Services;
using Timeline.Tests.Helpers;
-using Timeline.Tests.Mock.Data;
using Xunit;
namespace Timeline.Tests.Services
@@ -21,7 +20,7 @@ namespace Timeline.Tests.Services public UserDetailServiceTest()
{
_testDatabase = new TestDatabase();
- _service = new UserDetailService(_testDatabase.DatabaseContext, NullLogger<UserDetailService>.Instance);
+ _service = new UserDetailService(_testDatabase.Context, NullLogger<UserDetailService>.Instance);
}
public void Dispose()
@@ -51,7 +50,7 @@ namespace Timeline.Tests.Services {
const string nickname = "aaaaaa";
{
- var context = _testDatabase.DatabaseContext;
+ var context = _testDatabase.Context;
var userId = (await context.Users.Where(u => u.Name == MockUser.User.Username).Select(u => new { u.Id }).SingleAsync()).Id;
context.UserDetails.Add(new UserDetail
{
@@ -84,7 +83,7 @@ namespace Timeline.Tests.Services public async Task SetNickname_ShouldWork()
{
var username = MockUser.User.Username;
- var user = await _testDatabase.DatabaseContext.Users.Where(u => u.Name == username).Include(u => u.Detail).SingleAsync();
+ var user = await _testDatabase.Context.Users.Where(u => u.Name == username).Include(u => u.Detail).SingleAsync();
var nickname1 = "nickname1";
var nickname2 = "nickname2";
diff --git a/Timeline.Tests/Timeline.Tests.csproj b/Timeline.Tests/Timeline.Tests.csproj index 21e887eb..bf7a4fb4 100644 --- a/Timeline.Tests/Timeline.Tests.csproj +++ b/Timeline.Tests/Timeline.Tests.csproj @@ -15,8 +15,8 @@ <PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="5.2.7" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="3.0.0" />
<PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.6">
- <PrivateAssets>all</PrivateAssets>
- <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ <PrivateAssets>all</PrivateAssets>
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="3.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.3.0" />
@@ -31,8 +31,4 @@ <ItemGroup>
<ProjectReference Include="..\Timeline\Timeline.csproj" />
</ItemGroup>
-
- <ItemGroup>
- <Folder Include="Properties\" />
- </ItemGroup>
</Project>
diff --git a/Timeline.Tests/UsernameValidatorUnitTest.cs b/Timeline.Tests/UsernameValidatorUnitTest.cs index d02367be..e0f4633f 100644 --- a/Timeline.Tests/UsernameValidatorUnitTest.cs +++ b/Timeline.Tests/UsernameValidatorUnitTest.cs @@ -1,5 +1,6 @@ using FluentAssertions;
using Timeline.Models.Validation;
+using Timeline.Tests.Helpers;
using Xunit;
namespace Timeline.Tests
diff --git a/Timeline/Controllers/PersonalTimelineController.cs b/Timeline/Controllers/PersonalTimelineController.cs new file mode 100644 index 00000000..c864ed39 --- /dev/null +++ b/Timeline/Controllers/PersonalTimelineController.cs @@ -0,0 +1,168 @@ +using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Threading.Tasks;
+using Timeline.Auth;
+using Timeline.Filters;
+using Timeline.Models;
+using Timeline.Models.Http;
+using Timeline.Models.Validation;
+using Timeline.Services;
+using static Timeline.Resources.Controllers.TimelineController;
+
+namespace Timeline
+{
+ public static partial class ErrorCodes
+ {
+ public static partial class Http
+ {
+ public static class Timeline // ccc = 004
+ {
+ public const int PostListGetForbid = 10040101;
+ public const int PostOperationCreateForbid = 10040102;
+ public const int PostOperationDeleteForbid = 10040103;
+ public const int PostOperationDeleteNotExist = 10040201;
+ public const int ChangeMemberUserNotExist = 10040301;
+ }
+ }
+ }
+}
+
+namespace Timeline.Controllers
+{
+ [ApiController]
+ public class PersonalTimelineController : Controller
+ {
+ private readonly ILogger<PersonalTimelineController> _logger;
+
+ private readonly IPersonalTimelineService _service;
+
+ private bool IsAdmin()
+ {
+ if (User != null)
+ {
+ return User.IsAdministrator();
+ }
+ return false;
+ }
+
+ private string? GetAuthUsername()
+ {
+ if (User == null)
+ {
+ return null;
+ }
+ else
+ {
+ return User.Identity.Name;
+ }
+ }
+
+ public PersonalTimelineController(ILogger<PersonalTimelineController> logger, IPersonalTimelineService service)
+ {
+ _logger = logger;
+ _service = service;
+ }
+
+ [HttpGet("users/{username}/timeline")]
+ [CatchTimelineNotExistException]
+ public async Task<ActionResult<BaseTimelineInfo>> TimelineGet([FromRoute][Username] string username)
+ {
+ return await _service.GetTimeline(username);
+ }
+
+ [HttpGet("users/{username}/timeline/posts")]
+ [CatchTimelineNotExistException]
+ public async Task<ActionResult<IList<TimelinePostInfo>>> PostListGet([FromRoute][Username] string username)
+ {
+ if (!IsAdmin() && !await _service.HasReadPermission(username, GetAuthUsername()))
+ {
+ return StatusCode(StatusCodes.Status403Forbidden,
+ new CommonResponse(ErrorCodes.Http.Timeline.PostListGetForbid, MessagePostListGetForbid));
+ }
+
+ return await _service.GetPosts(username);
+ }
+
+ [HttpPost("users/{username}/timeline/postop/create")]
+ [Authorize]
+ [CatchTimelineNotExistException]
+ public async Task<ActionResult<TimelinePostCreateResponse>> PostOperationCreate([FromRoute][Username] string username, [FromBody] TimelinePostCreateRequest body)
+ {
+ if (!IsAdmin() && !await _service.IsMemberOf(username, GetAuthUsername()!))
+ {
+ return StatusCode(StatusCodes.Status403Forbidden,
+ new CommonResponse(ErrorCodes.Http.Timeline.PostOperationCreateForbid, MessagePostOperationCreateForbid));
+ }
+
+ var res = await _service.CreatePost(username, User.Identity.Name!, body.Content, body.Time);
+ return res;
+ }
+
+ [HttpPost("users/{username}/timeline/postop/delete")]
+ [Authorize]
+ [CatchTimelineNotExistException]
+ public async Task<ActionResult> PostOperationDelete([FromRoute][Username] string username, [FromBody] TimelinePostDeleteRequest body)
+ {
+ try
+ {
+ var postId = body.Id!.Value;
+ if (!IsAdmin() && !await _service.HasPostModifyPermission(username, postId, GetAuthUsername()!))
+ {
+ return StatusCode(StatusCodes.Status403Forbidden,
+ new CommonResponse(ErrorCodes.Http.Timeline.PostOperationDeleteForbid, MessagePostOperationCreateForbid));
+ }
+ await _service.DeletePost(username, postId);
+ }
+ catch (TimelinePostNotExistException)
+ {
+ return BadRequest(new CommonResponse(
+ ErrorCodes.Http.Timeline.PostOperationDeleteNotExist,
+ MessagePostOperationDeleteNotExist));
+ }
+ return Ok();
+ }
+
+ [HttpPost("users/{username}/timeline/op/property")]
+ [Authorize]
+ [SelfOrAdmin]
+ [CatchTimelineNotExistException]
+ public async Task<ActionResult> TimelineChangeProperty([FromRoute][Username] string username, [FromBody] TimelinePropertyChangeRequest body)
+ {
+ await _service.ChangeProperty(username, body);
+ return Ok();
+ }
+
+ [HttpPost("users/{username}/timeline/op/member")]
+ [Authorize]
+ [SelfOrAdmin]
+ [CatchTimelineNotExistException]
+ public async Task<ActionResult> TimelineChangeMember([FromRoute][Username] string username, [FromBody] TimelineMemberChangeRequest body)
+ {
+ try
+ {
+ await _service.ChangeMember(username, body.Add, body.Remove);
+ return Ok();
+ }
+ catch (TimelineMemberOperationUserException e)
+ {
+ if (e.InnerException is UsernameBadFormatException)
+ {
+ return BadRequest(CommonResponse.InvalidModel(
+ string.Format(CultureInfo.CurrentCulture, MessageMemberUsernameBadFormat, e.Index, e.Operation)));
+ }
+ else if (e.InnerException is UserNotExistException)
+ {
+ return BadRequest(new CommonResponse(ErrorCodes.Http.Timeline.ChangeMemberUserNotExist,
+ string.Format(CultureInfo.CurrentCulture, MessageMemberUserNotExist, e.Index, e.Operation)));
+ }
+
+ _logger.LogError(e, LogUnknownTimelineMemberOperationUserException);
+ throw;
+ }
+ }
+ }
+}
diff --git a/Timeline/Entities/DatabaseContext.cs b/Timeline/Entities/DatabaseContext.cs index 6c005b30..123ae0f3 100644 --- a/Timeline/Entities/DatabaseContext.cs +++ b/Timeline/Entities/DatabaseContext.cs @@ -20,5 +20,8 @@ namespace Timeline.Entities public DbSet<User> Users { get; set; } = default!;
public DbSet<UserAvatar> UserAvatars { get; set; } = default!;
public DbSet<UserDetail> UserDetails { get; set; } = default!;
+ public DbSet<TimelineEntity> Timelines { get; set; } = default!;
+ public DbSet<TimelinePostEntity> TimelinePosts { get; set; } = default!;
+ public DbSet<TimelineMemberEntity> TimelineMembers { get; set; } = default!;
}
}
diff --git a/Timeline/Entities/TimelineEntity.cs b/Timeline/Entities/TimelineEntity.cs new file mode 100644 index 00000000..9cacfcae --- /dev/null +++ b/Timeline/Entities/TimelineEntity.cs @@ -0,0 +1,41 @@ +using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+using Timeline.Models;
+
+namespace Timeline.Entities
+{
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "This is entity object.")]
+ [Table("timelines")]
+ public class TimelineEntity
+ {
+ [Column("id"), Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+ public long Id { get; set; }
+
+ /// <summary>
+ /// If null, then this timeline is a personal timeline.
+ /// </summary>
+ [Column("name")]
+ public string? Name { get; set; }
+
+ [Column("description")]
+ public string? Description { get; set; }
+
+ [Column("owner")]
+ public long OwnerId { get; set; }
+
+ [ForeignKey(nameof(OwnerId))]
+ public User Owner { get; set; } = default!;
+
+ [Column("visibility")]
+ public TimelineVisibility Visibility { get; set; }
+
+ [Column("create_time")]
+ public DateTime CreateTime { get; set; }
+
+ public List<TimelineMemberEntity> Members { get; set; } = default!;
+
+ public List<TimelinePostEntity> Posts { get; set; } = default!;
+ }
+}
diff --git a/Timeline/Entities/TimelineMemberEntity.cs b/Timeline/Entities/TimelineMemberEntity.cs new file mode 100644 index 00000000..c8961013 --- /dev/null +++ b/Timeline/Entities/TimelineMemberEntity.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace Timeline.Entities
+{
+ public class TimelineMemberEntity
+ {
+ [Column("id"), Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+ public long Id { get; set; }
+
+ [Column("user")]
+ public long UserId { get; set; }
+
+ [ForeignKey(nameof(UserId))]
+ public User User { get; set; } = default!;
+
+ [Column("timeline")]
+ public long TimelineId { get; set; }
+
+ [ForeignKey(nameof(TimelineId))]
+ public TimelineEntity Timeline { get; set; } = default!;
+ }
+}
diff --git a/Timeline/Entities/TimelinePostEntity.cs b/Timeline/Entities/TimelinePostEntity.cs new file mode 100644 index 00000000..efef3ab5 --- /dev/null +++ b/Timeline/Entities/TimelinePostEntity.cs @@ -0,0 +1,34 @@ +using System;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace Timeline.Entities
+{
+ [Table("timeline_posts")]
+ public class TimelinePostEntity
+ {
+ [Column("id"), Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+ public long Id { get; set; }
+
+ [Column("timeline")]
+ public long TimelineId { get; set; }
+
+ [ForeignKey(nameof(TimelineId))]
+ public TimelineEntity Timeline { get; set; } = default!;
+
+ [Column("author")]
+ public long AuthorId { get; set; }
+
+ [ForeignKey(nameof(AuthorId))]
+ public User Author { get; set; } = default!;
+
+ [Column("content")]
+ public string? Content { get; set; }
+
+ [Column("time")]
+ public DateTime Time { get; set; }
+
+ [Column("last_updated")]
+ public DateTime LastUpdated { get; set; }
+ }
+}
diff --git a/Timeline/Entities/User.cs b/Timeline/Entities/User.cs index 02352b03..e725a69a 100644 --- a/Timeline/Entities/User.cs +++ b/Timeline/Entities/User.cs @@ -1,4 +1,5 @@ -using System.ComponentModel.DataAnnotations;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Timeline.Entities
@@ -9,6 +10,7 @@ namespace Timeline.Entities public const string User = "user";
}
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "This is an entity class.")]
[Table("users")]
public class User
{
@@ -30,5 +32,11 @@ namespace Timeline.Entities public UserAvatar? Avatar { get; set; }
public UserDetail? Detail { get; set; }
+
+ public List<TimelineEntity> Timelines { get; set; } = default!;
+
+ public List<TimelinePostEntity> TimelinePosts { get; set; } = default!;
+
+ public List<TimelineMemberEntity> TimelinesJoined { get; set; } = default!;
}
}
diff --git a/Timeline/Entities/UserAvatar.cs b/Timeline/Entities/UserAvatar.cs index a5b18b94..114246f3 100644 --- a/Timeline/Entities/UserAvatar.cs +++ b/Timeline/Entities/UserAvatar.cs @@ -4,6 +4,7 @@ using System.ComponentModel.DataAnnotations.Schema; namespace Timeline.Entities
{
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1819:Properties should not return arrays", Justification = "This is data base entity.")]
[Table("user_avatars")]
public class UserAvatar
{
@@ -11,7 +12,6 @@ namespace Timeline.Entities public long Id { get; set; }
[Column("data")]
- [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1819:Properties should not return arrays", Justification = "This is data base entity.")]
public byte[]? Data { get; set; }
[Column("type")]
diff --git a/Timeline/Filters/Timeline.cs b/Timeline/Filters/Timeline.cs new file mode 100644 index 00000000..7859d409 --- /dev/null +++ b/Timeline/Filters/Timeline.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Filters;
+using Timeline.Models.Http;
+using Timeline.Services;
+using static Timeline.Resources.Filters;
+
+namespace Timeline
+{
+ public static partial class ErrorCodes
+ {
+ public static partial class Http
+ {
+ public static partial class Filter // bxx = 1xx
+ {
+ public static class Timeline // bbb = 102
+ {
+ public const int UserNotExist = 11020101;
+ public const int NameNotExist = 11020102;
+ }
+ }
+ }
+ }
+}
+
+namespace Timeline.Filters
+{
+ public class CatchTimelineNotExistExceptionAttribute : ExceptionFilterAttribute
+ {
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1062:Validate arguments of public methods")]
+ public override void OnException(ExceptionContext context)
+ {
+ if (context.Exception is TimelineNotExistException e)
+ {
+ if (e.InnerException is UserNotExistException)
+ {
+ context.Result = new BadRequestObjectResult(
+ new CommonResponse(ErrorCodes.Http.Filter.Timeline.UserNotExist, MessageTimelineNotExistUser));
+ }
+ else
+ {
+ context.Result = new BadRequestObjectResult(
+ new CommonResponse(ErrorCodes.Http.Filter.Timeline.NameNotExist, MessageTimelineNotExist));
+ }
+ }
+ }
+ }
+}
diff --git a/Timeline/Migrations/20191031064541_Initialize.cs b/Timeline/Migrations/20191031064541_Initialize.cs index 416f7c06..73521102 100644 --- a/Timeline/Migrations/20191031064541_Initialize.cs +++ b/Timeline/Migrations/20191031064541_Initialize.cs @@ -85,6 +85,7 @@ namespace Timeline.Migrations column: "name",
unique: true);
+ // Add a init user. Username is "administrator". Password is "crupest".
migrationBuilder.InsertData("users", new string[] { "name", "password", "roles" },
new object[] { "administrator", "AQAAAAEAACcQAAAAENsspZrk8Wo+UuMyg6QuWJsNvRg6gVu4K/TumVod3h9GVLX9zDVuQQds3o7V8QWJ2w==", "user,admin" });
}
diff --git a/Timeline/Models/Http/Timeline.cs b/Timeline/Models/Http/Timeline.cs new file mode 100644 index 00000000..06b88ad1 --- /dev/null +++ b/Timeline/Models/Http/Timeline.cs @@ -0,0 +1,45 @@ +using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using System.Threading.Tasks;
+using Timeline.Entities;
+
+namespace Timeline.Models.Http
+{
+ public class TimelinePostCreateRequest
+ {
+ [Required(AllowEmptyStrings = true)]
+ public string Content { get; set; } = default!;
+
+ public DateTime? Time { get; set; }
+ }
+
+ public class TimelinePostCreateResponse
+ {
+ public long Id { get; set; }
+
+ public DateTime Time { get; set; }
+ }
+
+ public class TimelinePostDeleteRequest
+ {
+ [Required]
+ public long? Id { get; set; }
+ }
+
+ public class TimelinePropertyChangeRequest
+ {
+ public string? Description { get; set; }
+
+ public TimelineVisibility? Visibility { get; set; }
+ }
+
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "This is a DTO class.")]
+ public class TimelineMemberChangeRequest
+ {
+ public List<string>? Add { get; set; }
+
+ public List<string>? Remove { get; set; }
+ }
+}
diff --git a/Timeline/Models/Timeline.cs b/Timeline/Models/Timeline.cs new file mode 100644 index 00000000..752c698d --- /dev/null +++ b/Timeline/Models/Timeline.cs @@ -0,0 +1,55 @@ +using System;
+using System.Collections.Generic;
+
+namespace Timeline.Models
+{
+ public enum TimelineVisibility
+ {
+ /// <summary>
+ /// All people including those without accounts.
+ /// </summary>
+ Public,
+ /// <summary>
+ /// Only people signed in.
+ /// </summary>
+ Register,
+ /// <summary>
+ /// Only member.
+ /// </summary>
+ Private
+ }
+
+ public class TimelinePostInfo
+ {
+ public long Id { get; set; }
+
+ public string? Content { get; set; }
+
+ public DateTime Time { get; set; }
+
+ /// <summary>
+ /// The username of the author.
+ /// </summary>
+ public string Author { get; set; } = default!;
+ }
+
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "This is a DTO class.")]
+ public class BaseTimelineInfo
+ {
+ public string? Description { get; set; }
+
+ /// <summary>
+ /// The username of the owner.
+ /// </summary>
+ public string Owner { get; set; } = default!;
+
+ public TimelineVisibility Visibility { get; set; }
+
+ public List<string> Members { get; set; } = default!;
+ }
+
+ public class TimelineInfo : BaseTimelineInfo
+ {
+ public string Name { get; set; } = default!;
+ }
+}
diff --git a/Timeline/Resources/Controllers/TimelineController.Designer.cs b/Timeline/Resources/Controllers/TimelineController.Designer.cs new file mode 100644 index 00000000..47c43fa2 --- /dev/null +++ b/Timeline/Resources/Controllers/TimelineController.Designer.cs @@ -0,0 +1,135 @@ +//------------------------------------------------------------------------------
+// <auto-generated>
+// This code was generated by a tool.
+// Runtime Version:4.0.30319.42000
+//
+// Changes to this file may cause incorrect behavior and will be lost if
+// the code is regenerated.
+// </auto-generated>
+//------------------------------------------------------------------------------
+
+namespace Timeline.Resources.Controllers {
+ using System;
+
+
+ /// <summary>
+ /// A strongly-typed resource class, for looking up localized strings, etc.
+ /// </summary>
+ // This class was auto-generated by the StronglyTypedResourceBuilder
+ // class via a tool like ResGen or Visual Studio.
+ // To add or remove a member, edit your .ResX file then rerun ResGen
+ // with the /str option, or rebuild your VS project.
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")]
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
+ [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
+ internal class TimelineController {
+
+ private static global::System.Resources.ResourceManager resourceMan;
+
+ private static global::System.Globalization.CultureInfo resourceCulture;
+
+ [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
+ internal TimelineController() {
+ }
+
+ /// <summary>
+ /// Returns the cached ResourceManager instance used by this class.
+ /// </summary>
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ internal static global::System.Resources.ResourceManager ResourceManager {
+ get {
+ if (object.ReferenceEquals(resourceMan, null)) {
+ global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Timeline.Resources.Controllers.TimelineController", typeof(TimelineController).Assembly);
+ resourceMan = temp;
+ }
+ return resourceMan;
+ }
+ }
+
+ /// <summary>
+ /// Overrides the current thread's CurrentUICulture property for all
+ /// resource lookups using this strongly typed resource class.
+ /// </summary>
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ internal static global::System.Globalization.CultureInfo Culture {
+ get {
+ return resourceCulture;
+ }
+ set {
+ resourceCulture = value;
+ }
+ }
+
+ /// <summary>
+ /// Looks up a localized string similar to An unknown timeline visibility value. Can't convert it..
+ /// </summary>
+ internal static string ExceptionStringToVisibility {
+ get {
+ return ResourceManager.GetString("ExceptionStringToVisibility", resourceCulture);
+ }
+ }
+
+ /// <summary>
+ /// Looks up a localized string similar to An unknown TimelineMemberOperationUserException is thrown. Can't recognize its inner exception. It is rethrown..
+ /// </summary>
+ internal static string LogUnknownTimelineMemberOperationUserException {
+ get {
+ return ResourceManager.GetString("LogUnknownTimelineMemberOperationUserException", resourceCulture);
+ }
+ }
+
+ /// <summary>
+ /// Looks up a localized string similar to The {0}-st username to do operation {1} on is of bad format..
+ /// </summary>
+ internal static string MessageMemberUsernameBadFormat {
+ get {
+ return ResourceManager.GetString("MessageMemberUsernameBadFormat", resourceCulture);
+ }
+ }
+
+ /// <summary>
+ /// Looks up a localized string similar to The {0}-st user to do operation {1} on does not exist..
+ /// </summary>
+ internal static string MessageMemberUserNotExist {
+ get {
+ return ResourceManager.GetString("MessageMemberUserNotExist", resourceCulture);
+ }
+ }
+
+ /// <summary>
+ /// Looks up a localized string similar to You have no permission to read posts of the timeline..
+ /// </summary>
+ internal static string MessagePostListGetForbid {
+ get {
+ return ResourceManager.GetString("MessagePostListGetForbid", resourceCulture);
+ }
+ }
+
+ /// <summary>
+ /// Looks up a localized string similar to You have no permission to create posts in the timeline..
+ /// </summary>
+ internal static string MessagePostOperationCreateForbid {
+ get {
+ return ResourceManager.GetString("MessagePostOperationCreateForbid", resourceCulture);
+ }
+ }
+
+ /// <summary>
+ /// Looks up a localized string similar to You have no permission to delete posts in the timeline..
+ /// </summary>
+ internal static string MessagePostOperationDeleteForbid {
+ get {
+ return ResourceManager.GetString("MessagePostOperationDeleteForbid", resourceCulture);
+ }
+ }
+
+ /// <summary>
+ /// Looks up a localized string similar to The post to delete does not exist..
+ /// </summary>
+ internal static string MessagePostOperationDeleteNotExist {
+ get {
+ return ResourceManager.GetString("MessagePostOperationDeleteNotExist", resourceCulture);
+ }
+ }
+ }
+}
diff --git a/Timeline/Resources/Controllers/TimelineController.resx b/Timeline/Resources/Controllers/TimelineController.resx new file mode 100644 index 00000000..0cf7e881 --- /dev/null +++ b/Timeline/Resources/Controllers/TimelineController.resx @@ -0,0 +1,144 @@ +<?xml version="1.0" encoding="utf-8"?>
+<root>
+ <!--
+ Microsoft ResX Schema
+
+ Version 2.0
+
+ The primary goals of this format is to allow a simple XML format
+ that is mostly human readable. The generation and parsing of the
+ various data types are done through the TypeConverter classes
+ associated with the data types.
+
+ Example:
+
+ ... ado.net/XML headers & schema ...
+ <resheader name="resmimetype">text/microsoft-resx</resheader>
+ <resheader name="version">2.0</resheader>
+ <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
+ <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
+ <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
+ <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
+ <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
+ <value>[base64 mime encoded serialized .NET Framework object]</value>
+ </data>
+ <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
+ <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
+ <comment>This is a comment</comment>
+ </data>
+
+ There are any number of "resheader" rows that contain simple
+ name/value pairs.
+
+ Each data row contains a name, and value. The row also contains a
+ type or mimetype. Type corresponds to a .NET class that support
+ text/value conversion through the TypeConverter architecture.
+ Classes that don't support this are serialized and stored with the
+ mimetype set.
+
+ The mimetype is used for serialized objects, and tells the
+ ResXResourceReader how to depersist the object. This is currently not
+ extensible. For a given mimetype the value must be set accordingly:
+
+ Note - application/x-microsoft.net.object.binary.base64 is the format
+ that the ResXResourceWriter will generate, however the reader can
+ read any of the formats listed below.
+
+ mimetype: application/x-microsoft.net.object.binary.base64
+ value : The object must be serialized with
+ : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
+ : and then encoded with base64 encoding.
+
+ mimetype: application/x-microsoft.net.object.soap.base64
+ value : The object must be serialized with
+ : System.Runtime.Serialization.Formatters.Soap.SoapFormatter
+ : and then encoded with base64 encoding.
+
+ mimetype: application/x-microsoft.net.object.bytearray.base64
+ value : The object must be serialized into a byte array
+ : using a System.ComponentModel.TypeConverter
+ : and then encoded with base64 encoding.
+ -->
+ <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
+ <xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
+ <xsd:element name="root" msdata:IsDataSet="true">
+ <xsd:complexType>
+ <xsd:choice maxOccurs="unbounded">
+ <xsd:element name="metadata">
+ <xsd:complexType>
+ <xsd:sequence>
+ <xsd:element name="value" type="xsd:string" minOccurs="0" />
+ </xsd:sequence>
+ <xsd:attribute name="name" use="required" type="xsd:string" />
+ <xsd:attribute name="type" type="xsd:string" />
+ <xsd:attribute name="mimetype" type="xsd:string" />
+ <xsd:attribute ref="xml:space" />
+ </xsd:complexType>
+ </xsd:element>
+ <xsd:element name="assembly">
+ <xsd:complexType>
+ <xsd:attribute name="alias" type="xsd:string" />
+ <xsd:attribute name="name" type="xsd:string" />
+ </xsd:complexType>
+ </xsd:element>
+ <xsd:element name="data">
+ <xsd:complexType>
+ <xsd:sequence>
+ <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+ <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
+ </xsd:sequence>
+ <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
+ <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
+ <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
+ <xsd:attribute ref="xml:space" />
+ </xsd:complexType>
+ </xsd:element>
+ <xsd:element name="resheader">
+ <xsd:complexType>
+ <xsd:sequence>
+ <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+ </xsd:sequence>
+ <xsd:attribute name="name" type="xsd:string" use="required" />
+ </xsd:complexType>
+ </xsd:element>
+ </xsd:choice>
+ </xsd:complexType>
+ </xsd:element>
+ </xsd:schema>
+ <resheader name="resmimetype">
+ <value>text/microsoft-resx</value>
+ </resheader>
+ <resheader name="version">
+ <value>2.0</value>
+ </resheader>
+ <resheader name="reader">
+ <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+ </resheader>
+ <resheader name="writer">
+ <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+ </resheader>
+ <data name="ExceptionStringToVisibility" xml:space="preserve">
+ <value>An unknown timeline visibility value. Can't convert it.</value>
+ </data>
+ <data name="LogUnknownTimelineMemberOperationUserException" xml:space="preserve">
+ <value>An unknown TimelineMemberOperationUserException is thrown. Can't recognize its inner exception. It is rethrown.</value>
+ </data>
+ <data name="MessageMemberUsernameBadFormat" xml:space="preserve">
+ <value>The {0}-st username to do operation {1} on is of bad format.</value>
+ </data>
+ <data name="MessageMemberUserNotExist" xml:space="preserve">
+ <value>The {0}-st user to do operation {1} on does not exist.</value>
+ </data>
+ <data name="MessagePostListGetForbid" xml:space="preserve">
+ <value>You have no permission to read posts of the timeline.</value>
+ </data>
+ <data name="MessagePostOperationCreateForbid" xml:space="preserve">
+ <value>You have no permission to create posts in the timeline.</value>
+ </data>
+ <data name="MessagePostOperationDeleteForbid" xml:space="preserve">
+ <value>You have no permission to delete posts in the timeline.</value>
+ </data>
+ <data name="MessagePostOperationDeleteNotExist" xml:space="preserve">
+ <value>The post to delete does not exist.</value>
+ </data>
+</root>
\ No newline at end of file diff --git a/Timeline/Resources/Controllers/TimelineController.zh.resx b/Timeline/Resources/Controllers/TimelineController.zh.resx new file mode 100644 index 00000000..170ab4cd --- /dev/null +++ b/Timeline/Resources/Controllers/TimelineController.zh.resx @@ -0,0 +1,138 @@ +<?xml version="1.0" encoding="utf-8"?>
+<root>
+ <!--
+ Microsoft ResX Schema
+
+ Version 2.0
+
+ The primary goals of this format is to allow a simple XML format
+ that is mostly human readable. The generation and parsing of the
+ various data types are done through the TypeConverter classes
+ associated with the data types.
+
+ Example:
+
+ ... ado.net/XML headers & schema ...
+ <resheader name="resmimetype">text/microsoft-resx</resheader>
+ <resheader name="version">2.0</resheader>
+ <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
+ <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
+ <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
+ <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
+ <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
+ <value>[base64 mime encoded serialized .NET Framework object]</value>
+ </data>
+ <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
+ <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
+ <comment>This is a comment</comment>
+ </data>
+
+ There are any number of "resheader" rows that contain simple
+ name/value pairs.
+
+ Each data row contains a name, and value. The row also contains a
+ type or mimetype. Type corresponds to a .NET class that support
+ text/value conversion through the TypeConverter architecture.
+ Classes that don't support this are serialized and stored with the
+ mimetype set.
+
+ The mimetype is used for serialized objects, and tells the
+ ResXResourceReader how to depersist the object. This is currently not
+ extensible. For a given mimetype the value must be set accordingly:
+
+ Note - application/x-microsoft.net.object.binary.base64 is the format
+ that the ResXResourceWriter will generate, however the reader can
+ read any of the formats listed below.
+
+ mimetype: application/x-microsoft.net.object.binary.base64
+ value : The object must be serialized with
+ : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
+ : and then encoded with base64 encoding.
+
+ mimetype: application/x-microsoft.net.object.soap.base64
+ value : The object must be serialized with
+ : System.Runtime.Serialization.Formatters.Soap.SoapFormatter
+ : and then encoded with base64 encoding.
+
+ mimetype: application/x-microsoft.net.object.bytearray.base64
+ value : The object must be serialized into a byte array
+ : using a System.ComponentModel.TypeConverter
+ : and then encoded with base64 encoding.
+ -->
+ <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
+ <xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
+ <xsd:element name="root" msdata:IsDataSet="true">
+ <xsd:complexType>
+ <xsd:choice maxOccurs="unbounded">
+ <xsd:element name="metadata">
+ <xsd:complexType>
+ <xsd:sequence>
+ <xsd:element name="value" type="xsd:string" minOccurs="0" />
+ </xsd:sequence>
+ <xsd:attribute name="name" use="required" type="xsd:string" />
+ <xsd:attribute name="type" type="xsd:string" />
+ <xsd:attribute name="mimetype" type="xsd:string" />
+ <xsd:attribute ref="xml:space" />
+ </xsd:complexType>
+ </xsd:element>
+ <xsd:element name="assembly">
+ <xsd:complexType>
+ <xsd:attribute name="alias" type="xsd:string" />
+ <xsd:attribute name="name" type="xsd:string" />
+ </xsd:complexType>
+ </xsd:element>
+ <xsd:element name="data">
+ <xsd:complexType>
+ <xsd:sequence>
+ <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+ <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
+ </xsd:sequence>
+ <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
+ <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
+ <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
+ <xsd:attribute ref="xml:space" />
+ </xsd:complexType>
+ </xsd:element>
+ <xsd:element name="resheader">
+ <xsd:complexType>
+ <xsd:sequence>
+ <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+ </xsd:sequence>
+ <xsd:attribute name="name" type="xsd:string" use="required" />
+ </xsd:complexType>
+ </xsd:element>
+ </xsd:choice>
+ </xsd:complexType>
+ </xsd:element>
+ </xsd:schema>
+ <resheader name="resmimetype">
+ <value>text/microsoft-resx</value>
+ </resheader>
+ <resheader name="version">
+ <value>2.0</value>
+ </resheader>
+ <resheader name="reader">
+ <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+ </resheader>
+ <resheader name="writer">
+ <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+ </resheader>
+ <data name="MessageMemberUsernameBadFormat" xml:space="preserve">
+ <value>第{0}个做{1}操作的用户名格式错误。</value>
+ </data>
+ <data name="MessageMemberUserNotExist" xml:space="preserve">
+ <value>第{0}个做{1}操作的用户不存在。</value>
+ </data>
+ <data name="MessagePostListGetForbid" xml:space="preserve">
+ <value>你没有权限读取这个时间线消息。</value>
+ </data>
+ <data name="MessagePostOperationCreateForbid" xml:space="preserve">
+ <value>你没有权限在这个时间线中创建消息。</value>
+ </data>
+ <data name="MessagePostOperationDeleteForbid" xml:space="preserve">
+ <value>你没有权限在这个时间线中删除消息。</value>
+ </data>
+ <data name="MessagePostOperationDeleteNotExist" xml:space="preserve">
+ <value>要删除的消息不存在。</value>
+ </data>
+</root>
\ No newline at end of file diff --git a/Timeline/Resources/Filters.Designer.cs b/Timeline/Resources/Filters.Designer.cs index 3481e4ae..5576190d 100644 --- a/Timeline/Resources/Filters.Designer.cs +++ b/Timeline/Resources/Filters.Designer.cs @@ -124,6 +124,24 @@ namespace Timeline.Resources { }
/// <summary>
+ /// Looks up a localized string similar to The requested timeline does not exist..
+ /// </summary>
+ internal static string MessageTimelineNotExist {
+ get {
+ return ResourceManager.GetString("MessageTimelineNotExist", resourceCulture);
+ }
+ }
+
+ /// <summary>
+ /// Looks up a localized string similar to The requested personal timeline does not exist because the user does not exist..
+ /// </summary>
+ internal static string MessageTimelineNotExistUser {
+ get {
+ return ResourceManager.GetString("MessageTimelineNotExistUser", resourceCulture);
+ }
+ }
+
+ /// <summary>
/// Looks up a localized string similar to The user does not exist..
/// </summary>
internal static string MessageUserNotExist {
diff --git a/Timeline/Resources/Filters.resx b/Timeline/Resources/Filters.resx index b91d4612..7bfbc703 100644 --- a/Timeline/Resources/Filters.resx +++ b/Timeline/Resources/Filters.resx @@ -138,6 +138,12 @@ <data name="MessageSelfOrAdminForbid" xml:space="preserve">
<value>You can't access the resource unless you are the owner or administrator.</value>
</data>
+ <data name="MessageTimelineNotExist" xml:space="preserve">
+ <value>The requested timeline does not exist.</value>
+ </data>
+ <data name="MessageTimelineNotExistUser" xml:space="preserve">
+ <value>The requested personal timeline does not exist because the user does not exist.</value>
+ </data>
<data name="MessageUserNotExist" xml:space="preserve">
<value>The user does not exist.</value>
</data>
diff --git a/Timeline/Resources/Filters.zh.resx b/Timeline/Resources/Filters.zh.resx index 159ac04a..36aac788 100644 --- a/Timeline/Resources/Filters.zh.resx +++ b/Timeline/Resources/Filters.zh.resx @@ -129,6 +129,12 @@ <data name="MessageSelfOrAdminForbid" xml:space="preserve">
<value>你无权访问该资源除非你是资源的拥有者或者管理员。</value>
</data>
+ <data name="MessageTimelineNotExist" xml:space="preserve">
+ <value>请求的时间线不存在。</value>
+ </data>
+ <data name="MessageTimelineNotExistUser" xml:space="preserve">
+ <value>请求的个人时间线不存在因为该用户不存在。</value>
+ </data>
<data name="MessageUserNotExist" xml:space="preserve">
<value>用户不存在。</value>
</data>
diff --git a/Timeline/Resources/Services/Exception.Designer.cs b/Timeline/Resources/Services/Exception.Designer.cs index ddf60f45..1b46f9e9 100644 --- a/Timeline/Resources/Services/Exception.Designer.cs +++ b/Timeline/Resources/Services/Exception.Designer.cs @@ -268,6 +268,69 @@ namespace Timeline.Resources.Services { }
/// <summary>
+ /// Looks up a localized string similar to The timeline with that name already exists..
+ /// </summary>
+ internal static string TimelineAlreadyExistException {
+ get {
+ return ResourceManager.GetString("TimelineAlreadyExistException", resourceCulture);
+ }
+ }
+
+ /// <summary>
+ /// Looks up a localized string similar to An exception happened when add or remove member on timeline..
+ /// </summary>
+ internal static string TimelineMemberOperationException {
+ get {
+ return ResourceManager.GetString("TimelineMemberOperationException", resourceCulture);
+ }
+ }
+
+ /// <summary>
+ /// Looks up a localized string similar to An exception happened when do operation {0} on the {1} member on timeline..
+ /// </summary>
+ internal static string TimelineMemberOperationExceptionDetail {
+ get {
+ return ResourceManager.GetString("TimelineMemberOperationExceptionDetail", resourceCulture);
+ }
+ }
+
+ /// <summary>
+ /// Looks up a localized string similar to Timeline name is of bad format. If this is a personal timeline, it means the username is of bad format and inner exception should be a UsernameBadFormatException..
+ /// </summary>
+ internal static string TimelineNameBadFormatException {
+ get {
+ return ResourceManager.GetString("TimelineNameBadFormatException", resourceCulture);
+ }
+ }
+
+ /// <summary>
+ /// Looks up a localized string similar to Timeline does not exist. If this is a personal timeline, it means the user does not exist and inner exception should be a UserNotExistException..
+ /// </summary>
+ internal static string TimelineNotExistException {
+ get {
+ return ResourceManager.GetString("TimelineNotExistException", resourceCulture);
+ }
+ }
+
+ /// <summary>
+ /// Looks up a localized string similar to The timeline post does not exist. You can't do operation on it..
+ /// </summary>
+ internal static string TimelinePostNotExistException {
+ get {
+ return ResourceManager.GetString("TimelinePostNotExistException", resourceCulture);
+ }
+ }
+
+ /// <summary>
+ /// Looks up a localized string similar to The use is not a member of the timeline..
+ /// </summary>
+ internal static string TimelineUserNotMemberException {
+ get {
+ return ResourceManager.GetString("TimelineUserNotMemberException", resourceCulture);
+ }
+ }
+
+ /// <summary>
/// Looks up a localized string similar to The username is of bad format..
/// </summary>
internal static string UsernameBadFormatException {
diff --git a/Timeline/Resources/Services/Exception.resx b/Timeline/Resources/Services/Exception.resx index 12bf9afb..1d9c0037 100644 --- a/Timeline/Resources/Services/Exception.resx +++ b/Timeline/Resources/Services/Exception.resx @@ -186,6 +186,27 @@ <data name="JwtVerifyExceptionVersionClaimBadFormat" xml:space="preserve">
<value>version claim is not a number.</value>
</data>
+ <data name="TimelineAlreadyExistException" xml:space="preserve">
+ <value>The timeline with that name already exists.</value>
+ </data>
+ <data name="TimelineMemberOperationException" xml:space="preserve">
+ <value>An exception happened when add or remove member on timeline.</value>
+ </data>
+ <data name="TimelineMemberOperationExceptionDetail" xml:space="preserve">
+ <value>An exception happened when do operation {0} on the {1} member on timeline.</value>
+ </data>
+ <data name="TimelineNameBadFormatException" xml:space="preserve">
+ <value>Timeline name is of bad format. If this is a personal timeline, it means the username is of bad format and inner exception should be a UsernameBadFormatException.</value>
+ </data>
+ <data name="TimelineNotExistException" xml:space="preserve">
+ <value>Timeline does not exist. If this is a personal timeline, it means the user does not exist and inner exception should be a UserNotExistException.</value>
+ </data>
+ <data name="TimelinePostNotExistException" xml:space="preserve">
+ <value>The timeline post does not exist. You can't do operation on it.</value>
+ </data>
+ <data name="TimelineUserNotMemberException" xml:space="preserve">
+ <value>The use is not a member of the timeline.</value>
+ </data>
<data name="UsernameBadFormatException" xml:space="preserve">
<value>The username is of bad format.</value>
</data>
diff --git a/Timeline/Services/TimelineAlreadyExistException.cs b/Timeline/Services/TimelineAlreadyExistException.cs new file mode 100644 index 00000000..c2dea1f9 --- /dev/null +++ b/Timeline/Services/TimelineAlreadyExistException.cs @@ -0,0 +1,17 @@ +using System;
+
+namespace Timeline.Services
+{
+ [Serializable]
+ public class TimelineAlreadyExistException : Exception
+ {
+ public TimelineAlreadyExistException() : base(Resources.Services.Exception.TimelineAlreadyExistException) { }
+ public TimelineAlreadyExistException(string name) : base(Resources.Services.Exception.TimelineAlreadyExistException) { Name = name; }
+ public TimelineAlreadyExistException(string name, Exception inner) : base(Resources.Services.Exception.TimelineAlreadyExistException, inner) { Name = name; }
+ protected TimelineAlreadyExistException(
+ System.Runtime.Serialization.SerializationInfo info,
+ System.Runtime.Serialization.StreamingContext context) : base(info, context) { }
+
+ public string? Name { get; set; }
+ }
+}
diff --git a/Timeline/Services/TimelineMemberOperationUserException.cs b/Timeline/Services/TimelineMemberOperationUserException.cs new file mode 100644 index 00000000..543ee160 --- /dev/null +++ b/Timeline/Services/TimelineMemberOperationUserException.cs @@ -0,0 +1,37 @@ +using System;
+using System.Globalization;
+
+namespace Timeline.Services
+{
+ [Serializable]
+ public class TimelineMemberOperationUserException : Exception
+ {
+ public enum MemberOperation
+ {
+ Add,
+ Remove
+ }
+
+ public TimelineMemberOperationUserException() : base(Resources.Services.Exception.TimelineMemberOperationException) { }
+ public TimelineMemberOperationUserException(string message) : base(message) { }
+ public TimelineMemberOperationUserException(string message, Exception inner) : base(message, inner) { }
+ protected TimelineMemberOperationUserException(
+ System.Runtime.Serialization.SerializationInfo info,
+ System.Runtime.Serialization.StreamingContext context) : base(info, context) { }
+
+ public TimelineMemberOperationUserException(int index, MemberOperation operation, string username, Exception inner)
+ : base(MakeMessage(operation, index), inner) { Operation = operation; Index = index; Username = username; }
+
+ private static string MakeMessage(MemberOperation operation, int index) => string.Format(CultureInfo.CurrentCulture,
+ Resources.Services.Exception.TimelineMemberOperationExceptionDetail, operation, index);
+
+ public MemberOperation? Operation { get; set; }
+
+ /// <summary>
+ /// The index of the member on which the operation failed.
+ /// </summary>
+ public int? Index { get; set; }
+
+ public string? Username { get; set; }
+ }
+}
diff --git a/Timeline/Services/TimelineNameBadFormatException.cs b/Timeline/Services/TimelineNameBadFormatException.cs new file mode 100644 index 00000000..5120a175 --- /dev/null +++ b/Timeline/Services/TimelineNameBadFormatException.cs @@ -0,0 +1,21 @@ +using System;
+
+namespace Timeline.Services
+{
+ [Serializable]
+ public class TimelineNameBadFormatException : Exception
+ {
+ public TimelineNameBadFormatException()
+ : base(Resources.Services.Exception.TimelineNameBadFormatException) { }
+ public TimelineNameBadFormatException(string name)
+ : base(Resources.Services.Exception.TimelineNameBadFormatException) { Name = name; }
+ public TimelineNameBadFormatException(string name, Exception inner)
+ : base(Resources.Services.Exception.TimelineNameBadFormatException, inner) { Name = name; }
+
+ protected TimelineNameBadFormatException(
+ System.Runtime.Serialization.SerializationInfo info,
+ System.Runtime.Serialization.StreamingContext context) : base(info, context) { }
+
+ public string? Name { get; set; }
+ }
+}
diff --git a/Timeline/Services/TimelineNotExistException.cs b/Timeline/Services/TimelineNotExistException.cs new file mode 100644 index 00000000..6dfd0bab --- /dev/null +++ b/Timeline/Services/TimelineNotExistException.cs @@ -0,0 +1,19 @@ +using System;
+
+namespace Timeline.Services
+{
+ [Serializable]
+ public class TimelineNotExistException : Exception
+ {
+ public TimelineNotExistException() : base(Resources.Services.Exception.TimelineNotExistException) { }
+ public TimelineNotExistException(string name)
+ : base(Resources.Services.Exception.TimelineNotExistException) { Name = name; }
+ public TimelineNotExistException(string name, Exception inner)
+ : base(Resources.Services.Exception.TimelineNotExistException, inner) { Name = name; }
+ protected TimelineNotExistException(
+ System.Runtime.Serialization.SerializationInfo info,
+ System.Runtime.Serialization.StreamingContext context) : base(info, context) { }
+
+ public string? Name { get; set; }
+ }
+}
diff --git a/Timeline/Services/TimelinePostNotExistException.cs b/Timeline/Services/TimelinePostNotExistException.cs new file mode 100644 index 00000000..97e5d550 --- /dev/null +++ b/Timeline/Services/TimelinePostNotExistException.cs @@ -0,0 +1,23 @@ +using System;
+
+namespace Timeline.Services
+{
+ [Serializable]
+ public class TimelinePostNotExistException : Exception
+ {
+ public TimelinePostNotExistException() { }
+ public TimelinePostNotExistException(string message) : base(message) { }
+ public TimelinePostNotExistException(string message, Exception inner) : base(message, inner) { }
+ protected TimelinePostNotExistException(
+ System.Runtime.Serialization.SerializationInfo info,
+ System.Runtime.Serialization.StreamingContext context) : base(info, context) { }
+
+ public TimelinePostNotExistException(long id) : base(Resources.Services.Exception.TimelinePostNotExistException) { Id = id; }
+
+ public TimelinePostNotExistException(long id, string message) : base(message) { Id = id; }
+
+ public TimelinePostNotExistException(long id, string message, Exception inner) : base(message, inner) { Id = id; }
+
+ public long Id { get; set; }
+ }
+}
diff --git a/Timeline/Services/TimelineService.cs b/Timeline/Services/TimelineService.cs new file mode 100644 index 00000000..affcff2e --- /dev/null +++ b/Timeline/Services/TimelineService.cs @@ -0,0 +1,730 @@ +using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Timeline.Entities;
+using Timeline.Models;
+using Timeline.Models.Http;
+using Timeline.Models.Validation;
+
+namespace Timeline.Services
+{
+ /// <summary>
+ /// This define the common interface of both personal timeline
+ /// and normal timeline.
+ /// </summary>
+ /// <remarks>
+ /// The "name" parameter in method means name of timeline in
+ /// <see cref="ITimelineService"/> while username of the owner
+ /// of the personal timeline in <see cref="IPersonalTimelineService"/>.
+ /// </remarks>
+ public interface IBaseTimelineService
+ {
+ /// <summary>
+ /// Get all the posts in the timeline.
+ /// </summary>
+ /// <param name="name">Username or the timeline name. See remarks of <see cref="IBaseTimelineService"/>.</param>
+ /// <returns>A list of all posts.</returns>
+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="name"/> is null.</exception>
+ /// <exception cref="TimelineNameBadFormatException">
+ /// Thrown when timeline name is of bad format.
+ /// For normal timeline, it means name is an empty string.
+ /// For personal timeline, it means the username is of bad format,
+ /// the inner exception should be a <see cref="UsernameBadFormatException"/>.
+ /// </exception>
+ /// <exception cref="TimelineNotExistException">
+ /// Thrown when timeline does not exist.
+ /// For normal timeline, it means the name does not exist.
+ /// For personal timeline, it means the user of that username does not exist
+ /// and the inner exception should be a <see cref="UserNotExistException"/>.
+ /// </exception>
+ Task<List<TimelinePostInfo>> GetPosts(string name);
+
+ /// <summary>
+ /// Create a new post in timeline.
+ /// </summary>
+ /// <param name="name">Username or the timeline name. See remarks of <ssee cref="IBaseTimelineService"/>.</param>
+ /// <param name="author">The author's username.</param>
+ /// <param name="content">The content.</param>
+ /// <param name="time">The time of the post. If null, then use current time.</param>
+ /// <returns>The info of the created post.</returns>
+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="name"/> or <paramref name="author"/> or <paramref name="content"/> is null.</exception>
+ /// <exception cref="TimelineNameBadFormatException">
+ /// Thrown when timeline name is of bad format.
+ /// For normal timeline, it means name is an empty string.
+ /// For personal timeline, it means the username is of bad format,
+ /// the inner exception should be a <see cref="UsernameBadFormatException"/>.
+ /// </exception>
+ /// <exception cref="TimelineNotExistException">
+ /// Thrown when timeline does not exist.
+ /// For normal timeline, it means the name does not exist.
+ /// For personal timeline, it means the user of that username does not exist
+ /// and the inner exception should be a <see cref="UserNotExistException"/>.
+ /// </exception>
+ /// <exception cref="UsernameBadFormatException">Thrown if <paramref name="author"/> is of bad format.</exception>
+ /// <exception cref="UserNotExistException">Thrown if <paramref name="author"/> does not exist.</exception>
+ Task<TimelinePostCreateResponse> CreatePost(string name, string author, string content, DateTime? time);
+
+ /// <summary>
+ /// Delete a post
+ /// </summary>
+ /// <param name="name">Username or the timeline name. See remarks of <see cref="IBaseTimelineService"/>.</param>
+ /// <param name="id">The id of the post to delete.</param>
+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="name"/> or <paramref name="username"/> is null.</exception>
+ /// <exception cref="TimelineNameBadFormatException">
+ /// Thrown when timeline name is of bad format.
+ /// For normal timeline, it means name is an empty string.
+ /// For personal timeline, it means the username is of bad format,
+ /// the inner exception should be a <see cref="UsernameBadFormatException"/>.
+ /// </exception>
+ /// <exception cref="TimelineNotExistException">
+ /// Thrown when timeline does not exist.
+ /// For normal timeline, it means the name does not exist.
+ /// For personal timeline, it means the user of that username does not exist
+ /// and the inner exception should be a <see cref="UserNotExistException"/>.
+ /// </exception>
+ /// <exception cref="TimelinePostNotExistException">
+ /// Thrown when the post with given id does not exist or is deleted already.
+ /// </exception>
+ /// <remarks>
+ /// First use <see cref="IBaseTimelineService.HasPostModifyPermission(string, long, string)"/>
+ /// to check the permission.
+ /// </remarks>
+ Task DeletePost(string name, long id);
+
+ /// <summary>
+ /// Set the properties of a timeline.
+ /// </summary>
+ /// <param name="name">Username or the timeline name. See remarks of <see cref="IBaseTimelineService"/>.</param>
+ /// <param name="newProperties">The new properties. Null member means not to change.</param>
+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="name"/> or <paramref name="newProperties"/> is null.</exception>
+ /// <exception cref="TimelineNameBadFormatException">
+ /// Thrown when timeline name is of bad format.
+ /// For normal timeline, it means name is an empty string.
+ /// For personal timeline, it means the username is of bad format,
+ /// the inner exception should be a <see cref="UsernameBadFormatException"/>.
+ /// </exception>
+ /// <exception cref="TimelineNotExistException">
+ /// Thrown when timeline does not exist.
+ /// For normal timeline, it means the name does not exist.
+ /// For personal timeline, it means the user of that username does not exist
+ /// and the inner exception should be a <see cref="UserNotExistException"/>.
+ /// </exception>
+ Task ChangeProperty(string name, TimelinePropertyChangeRequest newProperties);
+
+ /// <summary>
+ /// Remove members to a timeline.
+ /// </summary>
+ /// <param name="name">Username or the timeline name. See remarks of <see cref="IBaseTimelineService"/>.</param>
+ /// <param name="add">A list of usernames of members to add. May be null.</param>
+ /// <param name="remove">A list of usernames of members to remove. May be null.</param>
+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="name"/> is null.</exception>
+ /// <exception cref="TimelineNameBadFormatException">
+ /// Thrown when timeline name is of bad format.
+ /// For normal timeline, it means name is an empty string.
+ /// For personal timeline, it means the username is of bad format,
+ /// the inner exception should be a <see cref="UsernameBadFormatException"/>.
+ /// </exception>
+ /// <exception cref="TimelineNotExistException">
+ /// Thrown when timeline does not exist.
+ /// For normal timeline, it means the name does not exist.
+ /// For personal timeline, it means the user of that username does not exist
+ /// and the inner exception should be a <see cref="UserNotExistException"/>.
+ /// </exception>
+ /// <exception cref="TimelineMemberOperationUserException">
+ /// Thrown when an exception occurs on the user list.
+ /// The inner exception is <see cref="UsernameBadFormatException"/>
+ /// when one of the username is invalid.
+ /// The inner exception is <see cref="UserNotExistException"/>
+ /// when one of the user to change does not exist.
+ /// </exception>
+ /// <remarks>
+ /// Operating on a username that is of bad format or does not exist always throws.
+ /// Add a user that already is a member has no effects.
+ /// Remove a user that is not a member also has not effects.
+ /// Add and remove an identical user results in no effects.
+ /// More than one same usernames are regarded as one.
+ /// </remarks>
+ Task ChangeMember(string name, IList<string>? add, IList<string>? remove);
+
+ /// <summary>
+ /// Verify whether a visitor has the permission to read a timeline.
+ /// </summary>
+ /// <param name="name">Username or the timeline name. See remarks of <see cref="IBaseTimelineService"/>.</param>
+ /// <param name="username">The user to check on. Null means visitor without account.</param>
+ /// <returns>True if can read, false if can't read.</returns>
+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="name"/> is null.</exception>
+ /// <exception cref="TimelineNameBadFormatException">
+ /// Thrown when timeline name is of bad format.
+ /// For normal timeline, it means name is an empty string.
+ /// For personal timeline, it means the username is of bad format,
+ /// the inner exception should be a <see cref="UsernameBadFormatException"/>.
+ /// </exception>
+ /// <exception cref="TimelineNotExistException">
+ /// Thrown when timeline does not exist.
+ /// For normal timeline, it means the name does not exist.
+ /// For personal timeline, it means the user of that username does not exist
+ /// and the inner exception should be a <see cref="UserNotExistException"/>.
+ /// </exception>
+ /// <exception cref="UsernameBadFormatException">
+ /// Thrown when <paramref name="username"/> is of bad format.
+ /// </exception>
+ /// <exception cref="UserNotExistException">
+ /// Thrown when <paramref name="username"/> does not exist.
+ /// </exception>
+ Task<bool> HasReadPermission(string name, string? username);
+
+ /// <summary>
+ /// Verify whether a user has the permission to modify a post.
+ /// </summary>
+ /// <param name="name">Username or the timeline name. See remarks of <see cref="IBaseTimelineService"/>.</param>
+ /// <param name="username">The user to check on.</param>
+ /// <returns>True if can modify, false if can't modify.</returns>
+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="name"/> or <paramref name="username"/> is null.</exception>
+ /// <exception cref="TimelineNameBadFormatException">
+ /// Thrown when timeline name is of bad format.
+ /// For normal timeline, it means name is an empty string.
+ /// For personal timeline, it means the username is of bad format,
+ /// the inner exception should be a <see cref="UsernameBadFormatException"/>.
+ /// </exception>
+ /// <exception cref="TimelineNotExistException">
+ /// Thrown when timeline does not exist.
+ /// For normal timeline, it means the name does not exist.
+ /// For personal timeline, it means the user of that username does not exist
+ /// and the inner exception should be a <see cref="UserNotExistException"/>.
+ /// </exception>
+ /// <exception cref="TimelinePostNotExistException">
+ /// Thrown when the post with given id does not exist or is deleted already.
+ /// </exception>
+ /// <exception cref="UsernameBadFormatException">
+ /// Thrown when <paramref name="username"/> is of bad format.
+ /// </exception>
+ /// <exception cref="UserNotExistException">
+ /// Thrown when <paramref name="username"/> does not exist.
+ /// </exception>
+ /// <remarks>
+ /// This method does not check whether the user is administrator.
+ /// It only checks whether he is the author of the post or the owner of the timeline.
+ /// </remarks>
+ Task<bool> HasPostModifyPermission(string name, long id, string username);
+
+ /// <summary>
+ /// Verify whether a user is member of a timeline.
+ /// </summary>
+ /// <param name="name">Username or the timeline name. See remarks of <see cref="IBaseTimelineService"/>.</param>
+ /// <param name="username">The user to check on.</param>
+ /// <returns>True if it is a member, false if not.</returns>
+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="name"/> or <paramref name="username"/> is null.</exception>
+ /// <exception cref="TimelineNameBadFormatException">
+ /// Thrown when timeline name is of bad format.
+ /// For normal timeline, it means name is an empty string.
+ /// For personal timeline, it means the username is of bad format,
+ /// the inner exception should be a <see cref="UsernameBadFormatException"/>.
+ /// </exception>
+ /// <exception cref="TimelineNotExistException">
+ /// Thrown when timeline does not exist.
+ /// For normal timeline, it means the name does not exist.
+ /// For personal timeline, it means the user of that username does not exist
+ /// and the inner exception should be a <see cref="UserNotExistException"/>.
+ /// </exception>
+ /// <exception cref="UsernameBadFormatException">
+ /// Thrown when <paramref name="username"/> is not a valid username.
+ /// </exception>
+ /// <exception cref="UserNotExistException">
+ /// Thrown when user <paramref name="username"/> does not exist.
+ /// </exception>
+ /// <remarks>
+ /// Timeline owner is also considered as a member.
+ /// </remarks>
+ Task<bool> IsMemberOf(string name, string username);
+ }
+
+ /// <summary>
+ /// Service for normal timeline.
+ /// </summary>
+ public interface ITimelineService : IBaseTimelineService
+ {
+ /// <summary>
+ /// Get the timeline info.
+ /// </summary>
+ /// <param name="name">The name of the timeline.</param>
+ /// <returns>The timeline info.</returns>
+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="name"/> is null.</exception>
+ /// <exception cref="TimelineNameBadFormatException">
+ /// Thrown when timeline name is invalid. Currently it means it is an empty string.
+ /// </exception>
+ /// <exception cref="TimelineNotExistException">
+ /// Thrown when timeline with the name does not exist.
+ /// </exception>
+ Task<TimelineInfo> GetTimeline(string name);
+
+ /// <summary>
+ /// Create a timeline.
+ /// </summary>
+ /// <param name="name">The name of the timeline.</param>
+ /// <param name="owner">The owner of the timeline.</param>
+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="name"/> or <paramref name="owner"/> is null.</exception>
+ /// <exception cref="TimelineNameBadFormatException">
+ /// Thrown when timeline name is invalid. Currently it means it is an empty string.
+ /// </exception>
+ /// <exception cref="TimelineAlreadyExistException">
+ /// Thrown when the timeline already exists.
+ /// </exception>
+ /// <exception cref="UsernameBadFormatException">
+ /// Thrown when the username of the owner is not valid.
+ /// </exception>
+ /// <exception cref="UserNotExistException">
+ /// Thrown when the owner user does not exist.</exception>
+ Task CreateTimeline(string name, string owner);
+ }
+
+ public interface IPersonalTimelineService : IBaseTimelineService
+ {
+ /// <summary>
+ /// Get the timeline info.
+ /// </summary>
+ /// <param name="username">The username of the owner of the personal timeline.</param>
+ /// <returns>The timeline info.</returns>
+ /// <exception cref="ArgumentNullException">
+ /// Thrown when <paramref name="username"/> is null.
+ /// </exception>
+ /// <exception cref="TimelineNameBadFormatException">
+ /// Thrown when <paramref name="username"/> is of bad format. Inner exception MUST be <see cref="UsernameBadFormatException"/>.
+ /// </exception>
+ /// <exception cref="TimelineNotExistException">
+ /// Thrown when the user does not exist. Inner exception MUST be <see cref="UserNotExistException"/>.
+ /// </exception>
+ Task<BaseTimelineInfo> GetTimeline(string username);
+ }
+
+ public abstract class BaseTimelineService : IBaseTimelineService
+ {
+ protected BaseTimelineService(ILoggerFactory loggerFactory, DatabaseContext database, IClock clock)
+ {
+ Clock = clock;
+ Database = database;
+ }
+
+ protected IClock Clock { get; }
+
+ protected UsernameValidator UsernameValidator { get; } = new UsernameValidator();
+
+ protected DatabaseContext Database { get; }
+
+ /// <summary>
+ /// Find the timeline id by the name.
+ /// For details, see remarks.
+ /// </summary>
+ /// <param name="name">The username or the timeline name. See remarks.</param>
+ /// <returns>The id of the timeline entity.</returns>
+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="name"/> is null.</exception>
+ /// <exception cref="TimelineNameBadFormatException">
+ /// Thrown when timeline name is of bad format.
+ /// For normal timeline, it means name is an empty string.
+ /// For personal timeline, it means the username is of bad format,
+ /// the inner exception should be a <see cref="UsernameBadFormatException"/>.
+ /// </exception>
+ /// <exception cref="TimelineNotExistException">
+ /// Thrown when timeline does not exist.
+ /// For normal timeline, it means the name does not exist.
+ /// For personal timeline, it means the user of that username does not exist
+ /// and the inner exception should be a <see cref="UserNotExistException"/>.
+ /// </exception>
+ /// <remarks>
+ /// This is the common but different part for both types of timeline service.
+ /// For class that implements <see cref="IPersonalTimelineService"/>, this method should
+ /// find the timeline entity id by the given <paramref name="name"/> as the username of the owner.
+ /// For class that implements <see cref="ITimelineService"/>, this method should
+ /// find the timeline entity id by the given <paramref name="name"/> as the timeline name.
+ /// This method should be called by many other method that follows the contract.
+ /// </remarks>
+ protected abstract Task<long> FindTimelineId(string name);
+
+ public async Task<List<TimelinePostInfo>> GetPosts(string name)
+ {
+ if (name == null)
+ throw new ArgumentNullException(nameof(name));
+
+ var timelineId = await FindTimelineId(name);
+ var postEntities = await Database.TimelinePosts.OrderBy(p => p.Time).Where(p => p.TimelineId == timelineId && p.Content != null).ToListAsync();
+ var posts = new List<TimelinePostInfo>(await Task.WhenAll(postEntities.Select(async p => new TimelinePostInfo
+ {
+ Id = p.Id,
+ Content = p.Content,
+ Author = (await Database.Users.Where(u => u.Id == p.AuthorId).Select(u => new { u.Name }).SingleAsync()).Name,
+ Time = p.Time
+ })));
+ return posts;
+ }
+
+ public async Task<TimelinePostCreateResponse> CreatePost(string name, string author, string content, DateTime? time)
+ {
+ if (name == null)
+ throw new ArgumentNullException(nameof(name));
+ if (author == null)
+ throw new ArgumentNullException(nameof(author));
+ if (content == null)
+ throw new ArgumentNullException(nameof(content));
+
+ {
+ var (result, message) = UsernameValidator.Validate(author);
+ if (!result)
+ {
+ throw new UsernameBadFormatException(author, message);
+ }
+ }
+
+ var timelineId = await FindTimelineId(name);
+
+ var authorEntity = Database.Users.Where(u => u.Name == author).Select(u => new { u.Id }).SingleOrDefault();
+ if (authorEntity == null)
+ {
+ throw new UserNotExistException(author);
+ }
+ var authorId = authorEntity.Id;
+
+ var currentTime = Clock.GetCurrentTime();
+
+ var postEntity = new TimelinePostEntity
+ {
+ Content = content,
+ AuthorId = authorId,
+ TimelineId = timelineId,
+ Time = time ?? currentTime,
+ LastUpdated = currentTime
+ };
+
+ Database.TimelinePosts.Add(postEntity);
+ await Database.SaveChangesAsync();
+
+ return new TimelinePostCreateResponse
+ {
+ Id = postEntity.Id,
+ Time = postEntity.Time
+ };
+ }
+
+ public async Task DeletePost(string name, long id)
+ {
+ if (name == null)
+ throw new ArgumentNullException(nameof(name));
+
+ var timelineId = FindTimelineId(name);
+
+ var post = await Database.TimelinePosts.Where(p => p.Id == id).SingleOrDefaultAsync();
+
+ if (post == null)
+ throw new TimelinePostNotExistException(id);
+
+ post.Content = null;
+ post.LastUpdated = Clock.GetCurrentTime();
+
+ await Database.SaveChangesAsync();
+ }
+
+ public async Task ChangeProperty(string name, TimelinePropertyChangeRequest newProperties)
+ {
+ if (name == null)
+ throw new ArgumentNullException(nameof(name));
+ if (newProperties == null)
+ throw new ArgumentNullException(nameof(newProperties));
+
+ var timelineId = await FindTimelineId(name);
+
+ var timelineEntity = await Database.Timelines.Where(t => t.Id == timelineId).SingleAsync();
+
+ if (newProperties.Description != null)
+ {
+ timelineEntity.Description = newProperties.Description;
+ }
+
+ if (newProperties.Visibility.HasValue)
+ {
+ timelineEntity.Visibility = newProperties.Visibility.Value;
+ }
+
+ await Database.SaveChangesAsync();
+ }
+
+ public async Task ChangeMember(string name, IList<string>? add, IList<string>? remove)
+ {
+ if (name == null)
+ throw new ArgumentNullException(nameof(name));
+
+ // remove duplication and check the format of each username.
+ // Return a username->index map.
+ Dictionary<string, int>? RemoveDuplicateAndCheckFormat(IList<string>? list, TimelineMemberOperationUserException.MemberOperation operation)
+ {
+ if (list != null)
+ {
+ Dictionary<string, int> result = new Dictionary<string, int>();
+ var count = list.Count;
+ for (var index = 0; index < count; index++)
+ {
+ var username = list[index];
+ if (result.ContainsKey(username))
+ {
+ continue;
+ }
+ var (validationResult, message) = UsernameValidator.Validate(username);
+ if (!validationResult)
+ throw new TimelineMemberOperationUserException(
+ index, operation, username,
+ new UsernameBadFormatException(username, message));
+ result.Add(username, index);
+ }
+ return result;
+ }
+ else
+ {
+ return null;
+ }
+ }
+ var simplifiedAdd = RemoveDuplicateAndCheckFormat(add, TimelineMemberOperationUserException.MemberOperation.Add);
+ var simplifiedRemove = RemoveDuplicateAndCheckFormat(remove, TimelineMemberOperationUserException.MemberOperation.Remove);
+
+ // remove those both in add and remove
+ if (simplifiedAdd != null && simplifiedRemove != null)
+ {
+ var usersToClean = simplifiedRemove.Keys.Where(u => simplifiedAdd.ContainsKey(u));
+ foreach (var u in usersToClean)
+ {
+ simplifiedAdd.Remove(u);
+ simplifiedRemove.Remove(u);
+ }
+ }
+
+ var timelineId = await FindTimelineId(name);
+
+ async Task<List<long>?> CheckExistenceAndGetId(Dictionary<string, int>? map, TimelineMemberOperationUserException.MemberOperation operation)
+ {
+ if (map == null)
+ return null;
+
+ List<long> result = new List<long>();
+ foreach (var (username, index) in map)
+ {
+ var user = await Database.Users.Where(u => u.Name == username).Select(u => new { u.Id }).SingleOrDefaultAsync();
+ if (user == null)
+ {
+ throw new TimelineMemberOperationUserException(index, operation, username,
+ new UserNotExistException(username));
+ }
+ result.Add(user.Id);
+ }
+ return result;
+ }
+ var userIdsAdd = await CheckExistenceAndGetId(simplifiedAdd, TimelineMemberOperationUserException.MemberOperation.Add);
+ var userIdsRemove = await CheckExistenceAndGetId(simplifiedRemove, TimelineMemberOperationUserException.MemberOperation.Remove);
+
+ if (userIdsAdd != null)
+ {
+ var membersToAdd = userIdsAdd.Select(id => new TimelineMemberEntity { UserId = id, TimelineId = timelineId }).ToList();
+ Database.TimelineMembers.AddRange(membersToAdd);
+ }
+
+ if (userIdsRemove != null)
+ {
+ var membersToRemove = await Database.TimelineMembers.Where(m => m.TimelineId == timelineId && userIdsRemove.Contains(m.UserId)).ToListAsync();
+ Database.TimelineMembers.RemoveRange(membersToRemove);
+ }
+
+ await Database.SaveChangesAsync();
+ }
+
+ public async Task<bool> HasReadPermission(string name, string? username)
+ {
+ if (name == null)
+ throw new ArgumentNullException(nameof(name));
+
+ long? userId = null;
+ if (username != null)
+ {
+ var (result, message) = UsernameValidator.Validate(username);
+ if (!result)
+ {
+ throw new UsernameBadFormatException(username);
+ }
+
+ var user = await Database.Users.Where(u => u.Name == username).Select(u => new { u.Id }).SingleOrDefaultAsync();
+
+ if (user == null)
+ {
+ throw new UserNotExistException(username);
+ }
+
+ userId = user.Id;
+ }
+
+ var timelineId = await FindTimelineId(name);
+
+ var timelineEntity = await Database.Timelines.Where(t => t.Id == timelineId).Select(t => new { t.Visibility }).SingleAsync();
+
+ if (timelineEntity.Visibility == TimelineVisibility.Public)
+ return true;
+
+ if (timelineEntity.Visibility == TimelineVisibility.Register && username != null)
+ return true;
+
+ if (userId == null)
+ {
+ return false;
+ }
+ else
+ {
+ var memberEntity = await Database.TimelineMembers.Where(m => m.UserId == userId && m.TimelineId == timelineId).SingleOrDefaultAsync();
+ return memberEntity != null;
+ }
+ }
+
+ public async Task<bool> HasPostModifyPermission(string name, long id, string username)
+ {
+ if (name == null)
+ throw new ArgumentNullException(nameof(name));
+ if (username == null)
+ throw new ArgumentNullException(nameof(username));
+
+ {
+ var (result, message) = UsernameValidator.Validate(username);
+ if (!result)
+ {
+ throw new UsernameBadFormatException(username);
+ }
+ }
+
+ var user = await Database.Users.Where(u => u.Name == username).Select(u => new { u.Id }).SingleOrDefaultAsync();
+
+ if (user == null)
+ {
+ throw new UserNotExistException(username);
+ }
+
+ var userId = user.Id;
+
+ var timelineId = await FindTimelineId(name);
+
+ var timelineEntity = await Database.Timelines.Where(t => t.Id == timelineId).Select(t => new { t.OwnerId }).SingleAsync();
+
+ var postEntity = await Database.TimelinePosts.Where(p => p.Id == id).Select(p => new { p.AuthorId }).SingleOrDefaultAsync();
+
+ if (postEntity == null)
+ throw new TimelinePostNotExistException(id);
+
+ return timelineEntity.OwnerId == userId || postEntity.AuthorId == userId;
+ }
+
+ public async Task<bool> IsMemberOf(string name, string username)
+ {
+ if (name == null)
+ throw new ArgumentNullException(nameof(name));
+ if (username == null)
+ throw new ArgumentNullException(nameof(username));
+
+ {
+ var (result, message) = UsernameValidator.Validate(username);
+ if (!result)
+ {
+ throw new UsernameBadFormatException(username);
+ }
+ }
+
+ var user = await Database.Users.Where(u => u.Name == username).Select(u => new { u.Id }).SingleOrDefaultAsync();
+
+ if (user == null)
+ {
+ throw new UserNotExistException(username);
+ }
+
+ var userId = user.Id;
+
+ var timelineId = await FindTimelineId(name);
+
+ var timelineEntity = await Database.Timelines.Where(t => t.Id == timelineId).Select(t => new { t.OwnerId }).SingleAsync();
+
+ if (userId == timelineEntity.OwnerId)
+ return true;
+
+ var timelineMemberEntity = await Database.TimelineMembers.Where(m => m.TimelineId == timelineId && m.UserId == userId).SingleOrDefaultAsync();
+
+ return timelineMemberEntity != null;
+ }
+ }
+
+ public class PersonalTimelineService : BaseTimelineService, IPersonalTimelineService
+ {
+ public PersonalTimelineService(ILoggerFactory loggerFactory, DatabaseContext database, IClock clock)
+ : base(loggerFactory, database, clock)
+ {
+
+ }
+
+ protected override async Task<long> FindTimelineId(string name)
+ {
+ {
+ var (result, message) = UsernameValidator.Validate(name);
+ if (!result)
+ {
+ throw new TimelineNameBadFormatException(name, new UsernameBadFormatException(name, message));
+ }
+ }
+
+ var userEntity = await Database.Users.Where(u => u.Name == name).Select(u => new { u.Id }).SingleOrDefaultAsync();
+
+ if (userEntity == null)
+ {
+ throw new TimelineNotExistException(name, new UserNotExistException(name));
+ }
+
+ var userId = userEntity.Id;
+
+ var timelineEntity = await Database.Timelines.Where(t => t.OwnerId == userId && t.Name == null).Select(t => new { t.Id }).SingleOrDefaultAsync();
+
+ if (timelineEntity != null)
+ {
+ return timelineEntity.Id;
+ }
+ else
+ {
+ var newTimelineEntity = new TimelineEntity
+ {
+ Name = null,
+ Description = null,
+ OwnerId = userId,
+ Visibility = TimelineVisibility.Register,
+ CreateTime = Clock.GetCurrentTime(),
+ };
+ Database.Timelines.Add(newTimelineEntity);
+ await Database.SaveChangesAsync();
+
+ return newTimelineEntity.Id;
+ }
+ }
+
+ public async Task<BaseTimelineInfo> GetTimeline(string username)
+ {
+ if (username == null)
+ throw new ArgumentNullException(nameof(username));
+
+ var timelineId = await FindTimelineId(username);
+
+ var timelineEntity = await Database.Timelines.Where(t => t.Id == timelineId).SingleAsync();
+
+ var timelineMemberEntities = await Database.TimelineMembers.Where(m => m.TimelineId == timelineId).Select(m => new { m.UserId }).ToListAsync();
+
+ var memberUsernameTasks = timelineMemberEntities.Select(m => Database.Users.Where(u => u.Id == m.UserId).Select(u => u.Name).SingleAsync()).ToArray();
+
+ var memberUsernames = await Task.WhenAll(memberUsernameTasks);
+
+ return new BaseTimelineInfo
+ {
+ Description = timelineEntity.Description ?? "",
+ Owner = username,
+ Visibility = timelineEntity.Visibility,
+ Members = memberUsernames.ToList()
+ };
+ }
+
+ }
+}
diff --git a/Timeline/Services/UsernameBadFormatException.cs b/Timeline/Services/UsernameBadFormatException.cs index 04354d22..d82bf962 100644 --- a/Timeline/Services/UsernameBadFormatException.cs +++ b/Timeline/Services/UsernameBadFormatException.cs @@ -9,8 +9,8 @@ namespace Timeline.Services public class UsernameBadFormatException : Exception
{
public UsernameBadFormatException() : base(Resources.Services.Exception.UsernameBadFormatException) { }
- public UsernameBadFormatException(string message) : base(message) { }
- public UsernameBadFormatException(string message, Exception inner) : base(message, inner) { }
+ public UsernameBadFormatException(string username) : this() { Username = username; }
+ public UsernameBadFormatException(string username, Exception inner) : base(Resources.Services.Exception.UsernameBadFormatException, inner) { Username = username; }
public UsernameBadFormatException(string username, string message) : base(message) { Username = username; }
public UsernameBadFormatException(string username, string message, Exception inner) : base(message, inner) { Username = username; }
diff --git a/Timeline/Startup.cs b/Timeline/Startup.cs index f6abf36d..b2e958f9 100644 --- a/Timeline/Startup.cs +++ b/Timeline/Startup.cs @@ -71,6 +71,8 @@ namespace Timeline services.AddUserAvatarService();
services.AddScoped<IUserDetailService, UserDetailService>();
+ services.AddScoped<IPersonalTimelineService, PersonalTimelineService>();
+
var databaseConfig = Configuration.GetSection(nameof(DatabaseConfig)).Get<DatabaseConfig>();
services.AddDbContext<DatabaseContext>(options =>
diff --git a/Timeline/Timeline.csproj b/Timeline/Timeline.csproj index bd195475..f3589c04 100644 --- a/Timeline/Timeline.csproj +++ b/Timeline/Timeline.csproj @@ -1,7 +1,6 @@ <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>netcoreapp3.0</TargetFramework>
- <IsPackable>false</IsPackable>
<UserSecretsId>1f6fb74d-4277-4bc0-aeea-b1fc5ffb0b43</UserSecretsId>
<Authors>crupest</Authors>
@@ -24,8 +23,8 @@ <PackageReference Include="Microsoft.EntityFrameworkCore" Version="3.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Analyzers" Version="3.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="3.0.0">
- <PrivateAssets>all</PrivateAssets>
- <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ <PrivateAssets>all</PrivateAssets>
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="3.0.0-rc1.final" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql.Design" Version="1.1.2" />
@@ -34,139 +33,144 @@ </ItemGroup>
<ItemGroup>
- <Compile Update="Resources\Authentication\AuthHandler.Designer.cs">
- <DesignTime>True</DesignTime>
- <AutoGen>True</AutoGen>
- <DependentUpon>AuthHandler.resx</DependentUpon>
- </Compile>
- <Compile Update="Resources\Common.Designer.cs">
- <DesignTime>True</DesignTime>
- <AutoGen>True</AutoGen>
- <DependentUpon>Common.resx</DependentUpon>
- </Compile>
- <Compile Update="Resources\Controllers\Testing\TestingI18nController.Designer.cs">
- <DesignTime>True</DesignTime>
- <AutoGen>True</AutoGen>
- <DependentUpon>TestingI18nController.resx</DependentUpon>
- </Compile>
- <Compile Update="Resources\Controllers\TokenController.Designer.cs">
- <DesignTime>True</DesignTime>
- <AutoGen>True</AutoGen>
- <DependentUpon>TokenController.resx</DependentUpon>
- </Compile>
- <Compile Update="Resources\Controllers\UserAvatarController.Designer.cs">
- <DesignTime>True</DesignTime>
- <AutoGen>True</AutoGen>
- <DependentUpon>UserAvatarController.resx</DependentUpon>
- </Compile>
- <Compile Update="Resources\Controllers\UserController.Designer.cs">
- <DesignTime>True</DesignTime>
- <AutoGen>True</AutoGen>
- <DependentUpon>UserController.resx</DependentUpon>
- </Compile>
- <Compile Update="Resources\Filters.Designer.cs">
- <DesignTime>True</DesignTime>
- <AutoGen>True</AutoGen>
- <DependentUpon>Filters.resx</DependentUpon>
- </Compile>
- <Compile Update="Resources\Models\Http\Common.Designer.cs">
- <DesignTime>True</DesignTime>
- <AutoGen>True</AutoGen>
- <DependentUpon>Common.resx</DependentUpon>
- </Compile>
- <Compile Update="Resources\Models\Validation\UsernameValidator.Designer.cs">
- <DesignTime>True</DesignTime>
- <AutoGen>True</AutoGen>
- <DependentUpon>UsernameValidator.resx</DependentUpon>
- </Compile>
- <Compile Update="Resources\Models\Validation\Validator.Designer.cs">
- <DesignTime>True</DesignTime>
- <AutoGen>True</AutoGen>
- <DependentUpon>Validator.resx</DependentUpon>
- </Compile>
- <Compile Update="Resources\Services\Exception.Designer.cs">
- <DesignTime>True</DesignTime>
- <AutoGen>True</AutoGen>
- <DependentUpon>Exception.resx</DependentUpon>
- </Compile>
- <Compile Update="Resources\Services\UserAvatarService.Designer.cs">
- <DesignTime>True</DesignTime>
- <AutoGen>True</AutoGen>
- <DependentUpon>UserAvatarService.resx</DependentUpon>
- </Compile>
- <Compile Update="Resources\Services\UserDetailService.Designer.cs">
- <DesignTime>True</DesignTime>
- <AutoGen>True</AutoGen>
- <DependentUpon>UserDetailService.resx</DependentUpon>
- </Compile>
- <Compile Update="Resources\Services\UserService.Designer.cs">
- <DesignTime>True</DesignTime>
- <AutoGen>True</AutoGen>
- <DependentUpon>UserService.resx</DependentUpon>
- </Compile>
- </ItemGroup>
-
- <ItemGroup>
- <EmbeddedResource Update="Resources\Authentication\AuthHandler.resx">
- <Generator>ResXFileCodeGenerator</Generator>
- <LastGenOutput>AuthHandler.Designer.cs</LastGenOutput>
- </EmbeddedResource>
- <EmbeddedResource Update="Resources\Common.resx">
- <Generator>ResXFileCodeGenerator</Generator>
- <LastGenOutput>Common.Designer.cs</LastGenOutput>
- </EmbeddedResource>
- <EmbeddedResource Update="Resources\Controllers\Testing\TestingI18nController.resx">
- <Generator>ResXFileCodeGenerator</Generator>
- <LastGenOutput>TestingI18nController.Designer.cs</LastGenOutput>
- </EmbeddedResource>
- <EmbeddedResource Update="Resources\Controllers\TokenController.resx">
- <SubType>Designer</SubType>
- <Generator>ResXFileCodeGenerator</Generator>
- <LastGenOutput>TokenController.Designer.cs</LastGenOutput>
- </EmbeddedResource>
- <EmbeddedResource Update="Resources\Controllers\UserAvatarController.resx">
- <Generator>ResXFileCodeGenerator</Generator>
- <LastGenOutput>UserAvatarController.Designer.cs</LastGenOutput>
- </EmbeddedResource>
- <EmbeddedResource Update="Resources\Controllers\UserController.resx">
- <Generator>ResXFileCodeGenerator</Generator>
- <LastGenOutput>UserController.Designer.cs</LastGenOutput>
- </EmbeddedResource>
- <EmbeddedResource Update="Resources\Filters.resx">
- <Generator>ResXFileCodeGenerator</Generator>
- <LastGenOutput>Filters.Designer.cs</LastGenOutput>
- </EmbeddedResource>
- <EmbeddedResource Update="Resources\Models\Http\Common.resx">
- <Generator>ResXFileCodeGenerator</Generator>
- <LastGenOutput>Common.Designer.cs</LastGenOutput>
- </EmbeddedResource>
- <EmbeddedResource Update="Resources\Models\Validation\UsernameValidator.resx">
- <Generator>ResXFileCodeGenerator</Generator>
- <LastGenOutput>UsernameValidator.Designer.cs</LastGenOutput>
- </EmbeddedResource>
- <EmbeddedResource Update="Resources\Models\Validation\Validator.resx">
- <Generator>ResXFileCodeGenerator</Generator>
- <LastGenOutput>Validator.Designer.cs</LastGenOutput>
- </EmbeddedResource>
- <EmbeddedResource Update="Resources\Services\Exception.resx">
- <Generator>ResXFileCodeGenerator</Generator>
- <LastGenOutput>Exception.Designer.cs</LastGenOutput>
- </EmbeddedResource>
- <EmbeddedResource Update="Resources\Services\UserAvatarService.resx">
- <Generator>ResXFileCodeGenerator</Generator>
- <LastGenOutput>UserAvatarService.Designer.cs</LastGenOutput>
- </EmbeddedResource>
- <EmbeddedResource Update="Resources\Services\UserDetailService.resx">
- <Generator>ResXFileCodeGenerator</Generator>
- <LastGenOutput>UserDetailService.Designer.cs</LastGenOutput>
- </EmbeddedResource>
- <EmbeddedResource Update="Resources\Services\UserService.resx">
- <Generator>ResXFileCodeGenerator</Generator>
- <LastGenOutput>UserService.Designer.cs</LastGenOutput>
- </EmbeddedResource>
+ <Compile Update="Resources\Authentication\AuthHandler.Designer.cs">
+ <DesignTime>True</DesignTime>
+ <AutoGen>True</AutoGen>
+ <DependentUpon>AuthHandler.resx</DependentUpon>
+ </Compile>
+ <Compile Update="Resources\Common.Designer.cs">
+ <DesignTime>True</DesignTime>
+ <AutoGen>True</AutoGen>
+ <DependentUpon>Common.resx</DependentUpon>
+ </Compile>
+ <Compile Update="Resources\Controllers\Testing\TestingI18nController.Designer.cs">
+ <DesignTime>True</DesignTime>
+ <AutoGen>True</AutoGen>
+ <DependentUpon>TestingI18nController.resx</DependentUpon>
+ </Compile>
+ <Compile Update="Resources\Controllers\TimelineController.Designer.cs">
+ <DesignTime>True</DesignTime>
+ <AutoGen>True</AutoGen>
+ <DependentUpon>TimelineController.resx</DependentUpon>
+ </Compile>
+ <Compile Update="Resources\Controllers\TokenController.Designer.cs">
+ <DesignTime>True</DesignTime>
+ <AutoGen>True</AutoGen>
+ <DependentUpon>TokenController.resx</DependentUpon>
+ </Compile>
+ <Compile Update="Resources\Controllers\UserAvatarController.Designer.cs">
+ <DesignTime>True</DesignTime>
+ <AutoGen>True</AutoGen>
+ <DependentUpon>UserAvatarController.resx</DependentUpon>
+ </Compile>
+ <Compile Update="Resources\Controllers\UserController.Designer.cs">
+ <DesignTime>True</DesignTime>
+ <AutoGen>True</AutoGen>
+ <DependentUpon>UserController.resx</DependentUpon>
+ </Compile>
+ <Compile Update="Resources\Filters.Designer.cs">
+ <DesignTime>True</DesignTime>
+ <AutoGen>True</AutoGen>
+ <DependentUpon>Filters.resx</DependentUpon>
+ </Compile>
+ <Compile Update="Resources\Models\Http\Common.Designer.cs">
+ <DesignTime>True</DesignTime>
+ <AutoGen>True</AutoGen>
+ <DependentUpon>Common.resx</DependentUpon>
+ </Compile>
+ <Compile Update="Resources\Models\Validation\UsernameValidator.Designer.cs">
+ <DesignTime>True</DesignTime>
+ <AutoGen>True</AutoGen>
+ <DependentUpon>UsernameValidator.resx</DependentUpon>
+ </Compile>
+ <Compile Update="Resources\Models\Validation\Validator.Designer.cs">
+ <DesignTime>True</DesignTime>
+ <AutoGen>True</AutoGen>
+ <DependentUpon>Validator.resx</DependentUpon>
+ </Compile>
+ <Compile Update="Resources\Services\Exception.Designer.cs">
+ <DesignTime>True</DesignTime>
+ <AutoGen>True</AutoGen>
+ <DependentUpon>Exception.resx</DependentUpon>
+ </Compile>
+ <Compile Update="Resources\Services\UserAvatarService.Designer.cs">
+ <DesignTime>True</DesignTime>
+ <AutoGen>True</AutoGen>
+ <DependentUpon>UserAvatarService.resx</DependentUpon>
+ </Compile>
+ <Compile Update="Resources\Services\UserDetailService.Designer.cs">
+ <DesignTime>True</DesignTime>
+ <AutoGen>True</AutoGen>
+ <DependentUpon>UserDetailService.resx</DependentUpon>
+ </Compile>
+ <Compile Update="Resources\Services\UserService.Designer.cs">
+ <DesignTime>True</DesignTime>
+ <AutoGen>True</AutoGen>
+ <DependentUpon>UserService.resx</DependentUpon>
+ </Compile>
</ItemGroup>
<ItemGroup>
- <Folder Include="Resources\Filters\" />
+ <EmbeddedResource Update="Resources\Authentication\AuthHandler.resx">
+ <Generator>ResXFileCodeGenerator</Generator>
+ <LastGenOutput>AuthHandler.Designer.cs</LastGenOutput>
+ </EmbeddedResource>
+ <EmbeddedResource Update="Resources\Common.resx">
+ <Generator>ResXFileCodeGenerator</Generator>
+ <LastGenOutput>Common.Designer.cs</LastGenOutput>
+ </EmbeddedResource>
+ <EmbeddedResource Update="Resources\Controllers\Testing\TestingI18nController.resx">
+ <Generator>ResXFileCodeGenerator</Generator>
+ <LastGenOutput>TestingI18nController.Designer.cs</LastGenOutput>
+ </EmbeddedResource>
+ <EmbeddedResource Update="Resources\Controllers\TimelineController.resx">
+ <Generator>ResXFileCodeGenerator</Generator>
+ <LastGenOutput>TimelineController.Designer.cs</LastGenOutput>
+ </EmbeddedResource>
+ <EmbeddedResource Update="Resources\Controllers\TokenController.resx">
+ <SubType>Designer</SubType>
+ <Generator>ResXFileCodeGenerator</Generator>
+ <LastGenOutput>TokenController.Designer.cs</LastGenOutput>
+ </EmbeddedResource>
+ <EmbeddedResource Update="Resources\Controllers\UserAvatarController.resx">
+ <Generator>ResXFileCodeGenerator</Generator>
+ <LastGenOutput>UserAvatarController.Designer.cs</LastGenOutput>
+ </EmbeddedResource>
+ <EmbeddedResource Update="Resources\Controllers\UserController.resx">
+ <Generator>ResXFileCodeGenerator</Generator>
+ <LastGenOutput>UserController.Designer.cs</LastGenOutput>
+ </EmbeddedResource>
+ <EmbeddedResource Update="Resources\Filters.resx">
+ <Generator>ResXFileCodeGenerator</Generator>
+ <LastGenOutput>Filters.Designer.cs</LastGenOutput>
+ </EmbeddedResource>
+ <EmbeddedResource Update="Resources\Models\Http\Common.resx">
+ <Generator>ResXFileCodeGenerator</Generator>
+ <LastGenOutput>Common.Designer.cs</LastGenOutput>
+ </EmbeddedResource>
+ <EmbeddedResource Update="Resources\Models\Validation\UsernameValidator.resx">
+ <Generator>ResXFileCodeGenerator</Generator>
+ <LastGenOutput>UsernameValidator.Designer.cs</LastGenOutput>
+ </EmbeddedResource>
+ <EmbeddedResource Update="Resources\Models\Validation\Validator.resx">
+ <Generator>ResXFileCodeGenerator</Generator>
+ <LastGenOutput>Validator.Designer.cs</LastGenOutput>
+ </EmbeddedResource>
+ <EmbeddedResource Update="Resources\Services\Exception.resx">
+ <Generator>ResXFileCodeGenerator</Generator>
+ <LastGenOutput>Exception.Designer.cs</LastGenOutput>
+ </EmbeddedResource>
+ <EmbeddedResource Update="Resources\Services\UserAvatarService.resx">
+ <Generator>ResXFileCodeGenerator</Generator>
+ <LastGenOutput>UserAvatarService.Designer.cs</LastGenOutput>
+ </EmbeddedResource>
+ <EmbeddedResource Update="Resources\Services\UserDetailService.resx">
+ <Generator>ResXFileCodeGenerator</Generator>
+ <LastGenOutput>UserDetailService.Designer.cs</LastGenOutput>
+ </EmbeddedResource>
+ <EmbeddedResource Update="Resources\Services\UserService.resx">
+ <Generator>ResXFileCodeGenerator</Generator>
+ <LastGenOutput>UserService.Designer.cs</LastGenOutput>
+ </EmbeddedResource>
</ItemGroup>
</Project>
|