aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--ErrorResponseCodeGenerator/ErrorResponseCodeGenerator.csproj4
-rw-r--r--ErrorResponseCodeGenerator/Program.cs6
-rw-r--r--Timeline.ErrorCodes/ErrorCodes.cs6
-rw-r--r--Timeline.Tests/IntegratedTests/PersonalTimelineTest.cs16
-rw-r--r--Timeline.Tests/IntegratedTests/TimelineTest.cs589
-rw-r--r--Timeline/Controllers/PersonalTimelineController.cs16
-rw-r--r--Timeline/Controllers/TimelineController.cs149
-rw-r--r--Timeline/Entities/TimelineEntity.cs4
-rw-r--r--Timeline/Filters/Timeline.cs2
-rw-r--r--Timeline/Models/Http/ErrorResponse.cs31
-rw-r--r--Timeline/Models/Http/TimelineCommon.cs51
-rw-r--r--Timeline/Models/Http/TimelineController.cs8
-rw-r--r--Timeline/Models/Validation/NameValidator.cs33
-rw-r--r--Timeline/Models/Validation/TimelineNameValidator.cs19
-rw-r--r--Timeline/Models/Validation/UsernameValidator.cs28
-rw-r--r--Timeline/Resources/Messages.Designer.cs27
-rw-r--r--Timeline/Resources/Messages.resx15
-rw-r--r--Timeline/Resources/Models/Validation/NameValidator.Designer.cs (renamed from Timeline/Resources/Models/Validation/UsernameValidator.Designer.cs)6
-rw-r--r--Timeline/Resources/Models/Validation/NameValidator.resx (renamed from Timeline/Resources/Models/Validation/UsernameValidator.resx)0
-rw-r--r--Timeline/Resources/Services/TimelineService.Designer.cs18
-rw-r--r--Timeline/Resources/Services/TimelineService.resx6
-rw-r--r--Timeline/Services/TimelineService.cs261
-rw-r--r--Timeline/Startup.cs1
-rw-r--r--Timeline/Timeline.csproj8
24 files changed, 1128 insertions, 176 deletions
diff --git a/ErrorResponseCodeGenerator/ErrorResponseCodeGenerator.csproj b/ErrorResponseCodeGenerator/ErrorResponseCodeGenerator.csproj
index e77a1ba3..8524b2e4 100644
--- a/ErrorResponseCodeGenerator/ErrorResponseCodeGenerator.csproj
+++ b/ErrorResponseCodeGenerator/ErrorResponseCodeGenerator.csproj
@@ -6,6 +6,10 @@
</PropertyGroup>
<ItemGroup>
+ <PackageReference Include="TextCopy" Version="3.0.0" />
+ </ItemGroup>
+
+ <ItemGroup>
<ProjectReference Include="..\Timeline.ErrorCodes\Timeline.ErrorCodes.csproj" />
</ItemGroup>
diff --git a/ErrorResponseCodeGenerator/Program.cs b/ErrorResponseCodeGenerator/Program.cs
index cf021927..5ea8aaca 100644
--- a/ErrorResponseCodeGenerator/Program.cs
+++ b/ErrorResponseCodeGenerator/Program.cs
@@ -57,6 +57,12 @@ $
".Replace("$", code);
Console.WriteLine(code);
+
+ TextCopy.Clipboard.SetText(code);
+ var oldColor = Console.ForegroundColor;
+ Console.ForegroundColor = ConsoleColor.Green;
+ Console.WriteLine("Code has copied to clipboard!");
+ Console.ForegroundColor = oldColor;
}
}
}
diff --git a/Timeline.ErrorCodes/ErrorCodes.cs b/Timeline.ErrorCodes/ErrorCodes.cs
index ea74cf0e..e07fbd94 100644
--- a/Timeline.ErrorCodes/ErrorCodes.cs
+++ b/Timeline.ErrorCodes/ErrorCodes.cs
@@ -56,9 +56,11 @@
public const int BadFormat_BadSize = 1_103_00_03;
}
- public static class TimelineController
+ public static class TimelineCommon
{
- public const int MemberPut_NotExist = 1_104_01_01;
+ public const int NameConflict = 1_104_01_01;
+ public const int NotExist = 1_104_02_01;
+ public const int MemberPut_NotExist = 1_104_03_01;
}
}
}
diff --git a/Timeline.Tests/IntegratedTests/PersonalTimelineTest.cs b/Timeline.Tests/IntegratedTests/PersonalTimelineTest.cs
index 81446fd8..7d0a68e8 100644
--- a/Timeline.Tests/IntegratedTests/PersonalTimelineTest.cs
+++ b/Timeline.Tests/IntegratedTests/PersonalTimelineTest.cs
@@ -25,7 +25,7 @@ namespace Timeline.Tests.IntegratedTests
using var client = await CreateDefaultClient();
var res = await client.GetAsync("users/user1/timeline");
var body = res.Should().HaveStatusCode(200)
- .And.HaveJsonBody<BaseTimelineInfo>().Which;
+ .And.HaveJsonBody<TimelineInfo>().Which;
body.Owner.Should().BeEquivalentTo(UserInfos[1]);
body.Visibility.Should().Be(TimelineVisibility.Register);
body.Description.Should().Be("");
@@ -109,7 +109,7 @@ namespace Timeline.Tests.IntegratedTests
{
var res = await client.GetAsync("users/user1/timeline");
var body = res.Should().HaveStatusCode(200)
- .And.HaveJsonBody<BaseTimelineInfo>()
+ .And.HaveJsonBody<TimelineInfo>()
.Which.Description.Should().Be(description);
}
@@ -120,21 +120,21 @@ namespace Timeline.Tests.IntegratedTests
var res = await client.PatchAsJsonAsync("users/user1/timeline",
new TimelinePatchRequest { Description = mockDescription });
res.Should().HaveStatusCode(200)
- .And.HaveJsonBody<BaseTimelineInfo>().Which.Description.Should().Be(mockDescription);
+ .And.HaveJsonBody<TimelineInfo>().Which.Description.Should().Be(mockDescription);
await AssertDescription(mockDescription);
}
{
var res = await client.PatchAsJsonAsync("users/user1/timeline",
new TimelinePatchRequest { Description = null });
res.Should().HaveStatusCode(200)
- .And.HaveJsonBody<BaseTimelineInfo>().Which.Description.Should().Be(mockDescription);
+ .And.HaveJsonBody<TimelineInfo>().Which.Description.Should().Be(mockDescription);
await AssertDescription(mockDescription);
}
{
var res = await client.PatchAsJsonAsync("users/user1/timeline",
new TimelinePatchRequest { Description = "" });
res.Should().HaveStatusCode(200)
- .And.HaveJsonBody<BaseTimelineInfo>().Which.Description.Should().Be("");
+ .And.HaveJsonBody<TimelineInfo>().Which.Description.Should().Be("");
await AssertDescription("");
}
}
@@ -149,7 +149,7 @@ namespace Timeline.Tests.IntegratedTests
{
var res = await client.GetAsync(getUrl);
res.Should().HaveStatusCode(200)
- .And.HaveJsonBody<BaseTimelineInfo>()
+ .And.HaveJsonBody<TimelineInfo>()
.Which.Members.Should().NotBeNull().And.BeEquivalentTo(members);
}
@@ -157,7 +157,7 @@ namespace Timeline.Tests.IntegratedTests
{
var res = await client.GetAsync(getUrl);
res.Should().HaveStatusCode(200)
- .And.HaveJsonBody<BaseTimelineInfo>()
+ .And.HaveJsonBody<TimelineInfo>()
.Which.Members.Should().NotBeNull().And.BeEmpty();
}
@@ -165,7 +165,7 @@ namespace Timeline.Tests.IntegratedTests
{
var res = await client.PutAsync("/users/user1/timeline/members/usernotexist", null);
res.Should().HaveStatusCode(400)
- .And.HaveCommonBody(ErrorCodes.TimelineController.MemberPut_NotExist);
+ .And.HaveCommonBody(ErrorCodes.TimelineCommon.MemberPut_NotExist);
}
await AssertEmptyMembers();
{
diff --git a/Timeline.Tests/IntegratedTests/TimelineTest.cs b/Timeline.Tests/IntegratedTests/TimelineTest.cs
new file mode 100644
index 00000000..58066d71
--- /dev/null
+++ b/Timeline.Tests/IntegratedTests/TimelineTest.cs
@@ -0,0 +1,589 @@
+using FluentAssertions;
+using Microsoft.AspNetCore.Mvc.Testing;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Threading.Tasks;
+using Timeline.Models.Http;
+using Timeline.Tests.Helpers;
+using Xunit;
+
+namespace Timeline.Tests.IntegratedTests
+{
+ public class TimelineTest : IntegratedTestBase
+ {
+ public TimelineTest(WebApplicationFactory<Startup> factory)
+ : base(factory, 3)
+ {
+
+ }
+
+ private List<TimelineInfo> _testTimelines;
+
+ private async Task CreateTestTimelines()
+ {
+ _testTimelines = new List<TimelineInfo>();
+ for (int i = 0; i <= 3; i++)
+ {
+ var client = await CreateClientAs(i);
+ var res = await client.PostAsJsonAsync("timelines", new TimelineCreateRequest { Name = $"t{i}" });
+ var timelineInfo = res.Should().HaveStatusCode(200)
+ .And.HaveJsonBody<TimelineInfo>().Which;
+ _testTimelines.Add(timelineInfo);
+ }
+ }
+
+ [Fact]
+ public async Task TimelineCreate_Should_Work()
+ {
+ {
+ using var client = await CreateDefaultClient();
+ var res = await client.PostAsJsonAsync("timelines", new TimelineCreateRequest { Name = "aaa" });
+ res.Should().HaveStatusCode(HttpStatusCode.Unauthorized);
+ }
+
+ using (var client = await CreateClientAsUser())
+ {
+ {
+ var res = await client.PostAsJsonAsync("timelines", new TimelineCreateRequest { Name = "!!!" });
+ res.Should().BeInvalidModel();
+ }
+
+ TimelineInfo timelineInfo;
+ {
+ var res = await client.PostAsJsonAsync("timelines", new TimelineCreateRequest { Name = "aaa" });
+ timelineInfo = res.Should().HaveStatusCode(200)
+ .And.HaveJsonBody<TimelineInfo>().Which;
+ }
+
+ {
+ var res = await client.GetAsync("timelines/aaa");
+ res.Should().HaveStatusCode(200)
+ .And.HaveJsonBody<TimelineInfo>()
+ .Which.Should().BeEquivalentTo(timelineInfo);
+ }
+
+ {
+ var res = await client.PostAsJsonAsync("timelines", new TimelineCreateRequest { Name = "aaa" });
+ res.Should().HaveStatusCode(400)
+ .And.HaveCommonBody(ErrorCodes.TimelineCommon.NameConflict);
+ }
+ }
+ }
+
+ [Fact]
+ public async Task InvalidModel_BadName()
+ {
+ using var client = await CreateClientAsAdministrator();
+ {
+ var res = await client.GetAsync("timelines/aaa!!!");
+ res.Should().BeInvalidModel();
+ }
+ {
+ var res = await client.PatchAsJsonAsync("timelines/aaa!!!", new TimelinePatchRequest { });
+ res.Should().BeInvalidModel();
+ }
+ {
+ var res = await client.PutAsync("timelines/aaa!!!/members/user1", null);
+ res.Should().BeInvalidModel();
+ }
+ {
+ var res = await client.DeleteAsync("timelines/aaa!!!/members/user1");
+ res.Should().BeInvalidModel();
+ }
+ {
+ var res = await client.GetAsync("timelines/aaa!!!/posts");
+ res.Should().BeInvalidModel();
+ }
+ {
+ var res = await client.PostAsJsonAsync("timelines/aaa!!!/posts", new TimelinePostCreateRequest { Content = "aaa" });
+ res.Should().BeInvalidModel();
+ }
+ {
+ var res = await client.DeleteAsync("timelines/aaa!!!/posts/123");
+ res.Should().BeInvalidModel();
+ }
+ }
+
+ [Fact]
+ public async Task NotFound()
+ {
+ using var client = await CreateClientAsAdministrator();
+ {
+ var res = await client.GetAsync("timelines/notexist");
+ res.Should().HaveStatusCode(404).And.HaveCommonBody(ErrorCodes.TimelineCommon.NotExist);
+ }
+ {
+ var res = await client.PatchAsJsonAsync("timelines/notexist", new TimelinePatchRequest { });
+ res.Should().HaveStatusCode(404).And.HaveCommonBody(ErrorCodes.TimelineCommon.NotExist);
+ }
+ {
+ var res = await client.PutAsync("timelines/notexist/members/user1", null);
+ res.Should().HaveStatusCode(404).And.HaveCommonBody(ErrorCodes.TimelineCommon.NotExist);
+ }
+ {
+ var res = await client.DeleteAsync("timelines/notexist/members/user1");
+ res.Should().HaveStatusCode(404).And.HaveCommonBody(ErrorCodes.TimelineCommon.NotExist);
+ }
+ {
+ var res = await client.GetAsync("timelines/notexist/posts");
+ res.Should().HaveStatusCode(404).And.HaveCommonBody(ErrorCodes.TimelineCommon.NotExist);
+ }
+ {
+ var res = await client.PostAsJsonAsync("timelines/notexist/posts", new TimelinePostCreateRequest { Content = "aaa" });
+ res.Should().HaveStatusCode(404).And.HaveCommonBody(ErrorCodes.TimelineCommon.NotExist);
+ }
+ {
+ var res = await client.DeleteAsync("timelines/notexist/posts/123");
+ res.Should().HaveStatusCode(404).And.HaveCommonBody(ErrorCodes.TimelineCommon.NotExist);
+ }
+ }
+
+ [Fact]
+ public async Task Description_Should_Work()
+ {
+ await CreateTestTimelines();
+
+ using var client = await CreateClientAsUser();
+
+ async Task AssertDescription(string description)
+ {
+ var res = await client.GetAsync("timelines/t1");
+ var body = res.Should().HaveStatusCode(200)
+ .And.HaveJsonBody<TimelineInfo>()
+ .Which.Description.Should().Be(description);
+ }
+
+ const string mockDescription = "haha";
+
+ await AssertDescription("");
+ {
+ var res = await client.PatchAsJsonAsync("timelines/t1",
+ new TimelinePatchRequest { Description = mockDescription });
+ res.Should().HaveStatusCode(200)
+ .And.HaveJsonBody<TimelineInfo>().Which.Description.Should().Be(mockDescription);
+ await AssertDescription(mockDescription);
+ }
+ {
+ var res = await client.PatchAsJsonAsync("timelines/t1",
+ new TimelinePatchRequest { Description = null });
+ res.Should().HaveStatusCode(200)
+ .And.HaveJsonBody<TimelineInfo>().Which.Description.Should().Be(mockDescription);
+ await AssertDescription(mockDescription);
+ }
+ {
+ var res = await client.PatchAsJsonAsync("timelines/t1",
+ new TimelinePatchRequest { Description = "" });
+ res.Should().HaveStatusCode(200)
+ .And.HaveJsonBody<TimelineInfo>().Which.Description.Should().Be("");
+ await AssertDescription("");
+ }
+ }
+
+ [Fact]
+ public async Task Member_Should_Work()
+ {
+ await CreateTestTimelines();
+
+ const string getUrl = "timelines/t1";
+ using var client = await CreateClientAsUser();
+
+ async Task AssertMembers(IList<UserInfo> members)
+ {
+ var res = await client.GetAsync(getUrl);
+ res.Should().HaveStatusCode(200)
+ .And.HaveJsonBody<TimelineInfo>()
+ .Which.Members.Should().NotBeNull().And.BeEquivalentTo(members);
+ }
+
+ async Task AssertEmptyMembers()
+ {
+ var res = await client.GetAsync(getUrl);
+ res.Should().HaveStatusCode(200)
+ .And.HaveJsonBody<TimelineInfo>()
+ .Which.Members.Should().NotBeNull().And.BeEmpty();
+ }
+
+ await AssertEmptyMembers();
+ {
+ var res = await client.PutAsync("/timelines/t1/members/usernotexist", null);
+ res.Should().HaveStatusCode(400)
+ .And.HaveCommonBody(ErrorCodes.TimelineCommon.MemberPut_NotExist);
+ }
+ await AssertEmptyMembers();
+ {
+ var res = await client.PutAsync("/timelines/t1/members/user2", null);
+ res.Should().HaveStatusCode(200);
+ }
+ await AssertMembers(new List<UserInfo> { UserInfos[2] });
+ {
+ var res = await client.DeleteAsync("/timelines/t1/members/user2");
+ res.Should().BeDelete(true);
+ }
+ await AssertEmptyMembers();
+ {
+ var res = await client.DeleteAsync("/timelines/t1/members/users2");
+ res.Should().BeDelete(false);
+ }
+ await AssertEmptyMembers();
+ }
+
+ [Theory]
+ [InlineData(-1, 200, 401, 401, 401, 401)]
+ [InlineData(1, 200, 200, 403, 200, 403)]
+ [InlineData(0, 200, 200, 200, 200, 200)]
+ public async Task Permission_Timeline(int userNumber, int get, int opPatchUser, int opPatchAdmin, int opMemberUser, int opMemberAdmin)
+ {
+ await CreateTestTimelines();
+
+ using var client = await CreateClientAs(userNumber);
+ {
+ var res = await client.GetAsync("timelines/t1");
+ res.Should().HaveStatusCode(get);
+ }
+
+ {
+ var res = await client.PatchAsJsonAsync("timelines/t1", new TimelinePatchRequest { Description = "hahaha" });
+ res.Should().HaveStatusCode(opPatchUser);
+ }
+
+ {
+ var res = await client.PatchAsJsonAsync("timelines/t0", new TimelinePatchRequest { Description = "hahaha" });
+ res.Should().HaveStatusCode(opPatchAdmin);
+ }
+
+ {
+ var res = await client.PutAsync("timelines/t1/members/user2", null);
+ res.Should().HaveStatusCode(opMemberUser);
+ }
+
+ {
+ var res = await client.DeleteAsync("timelines/t1/members/user2");
+ res.Should().HaveStatusCode(opMemberUser);
+ }
+
+ {
+ var res = await client.PutAsync("timelines/t0/members/user2", null);
+ res.Should().HaveStatusCode(opMemberAdmin);
+ }
+
+ {
+ var res = await client.DeleteAsync("timelines/t0/members/user2");
+ res.Should().HaveStatusCode(opMemberAdmin);
+ }
+ }
+
+ [Fact]
+ public async Task Visibility_Test()
+ {
+ await CreateTestTimelines();
+
+ const string userUrl = "timelines/t1/posts";
+ const string adminUrl = "timelines/t0/posts";
+ {
+
+ using var client = await CreateClientAsUser();
+ using var content = new StringContent(@"{""visibility"":""abcdefg""}", System.Text.Encoding.UTF8, System.Net.Mime.MediaTypeNames.Application.Json);
+ var res = await client.PatchAsync("timelines/t1", content);
+ res.Should().BeInvalidModel();
+ }
+ { // default visibility is registered
+ {
+ using var client = await CreateDefaultClient();
+ 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.PatchAsJsonAsync("timelines/t1",
+ new TimelinePatchRequest { Visibility = TimelineVisibility.Public });
+ res.Should().HaveStatusCode(200);
+ }
+ {
+ using var client = await CreateDefaultClient();
+ var res = await client.GetAsync(userUrl);
+ res.Should().HaveStatusCode(200);
+ }
+ }
+
+ { // change visibility to private
+ {
+ using var client = await CreateClientAsAdministrator();
+ {
+ var res = await client.PatchAsJsonAsync("timelines/t1",
+ new TimelinePatchRequest { Visibility = TimelineVisibility.Private });
+ res.Should().HaveStatusCode(200);
+ }
+ {
+ var res = await client.PatchAsJsonAsync("timelines/t0",
+ new TimelinePatchRequest { Visibility = TimelineVisibility.Private });
+ res.Should().HaveStatusCode(200);
+ }
+ }
+ {
+ using var client = await CreateDefaultClient();
+ 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 CreateClientAsAdministrator();
+ var res = await client.GetAsync(userUrl);
+ res.Should().HaveStatusCode(200);
+ }
+ { // add member
+ using var client = await CreateClientAsAdministrator();
+ var res = await client.PutAsync("/timelines/t0/members/user1", null);
+ 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()
+ {
+ await CreateTestTimelines();
+
+ using (var client = await CreateClientAsUser())
+ {
+ var res = await client.PutAsync("timelines/t1/members/user2", null);
+ res.Should().HaveStatusCode(200);
+ }
+
+ using (var client = await CreateDefaultClient())
+ {
+ { // no auth should get 401
+ var res = await client.PostAsJsonAsync("timelines/t1/posts",
+ new TimelinePostCreateRequest { Content = "aaa" });
+ res.Should().HaveStatusCode(401);
+ }
+ }
+
+ using (var client = await CreateClientAsUser())
+ {
+ { // post self's
+ var res = await client.PostAsJsonAsync("timelines/t1/posts",
+ new TimelinePostCreateRequest { Content = "aaa" });
+ res.Should().HaveStatusCode(200);
+ }
+ { // post other not as a member should get 403
+ var res = await client.PostAsJsonAsync("timelines/t0/posts",
+ new TimelinePostCreateRequest { Content = "aaa" });
+ res.Should().HaveStatusCode(403);
+ }
+ }
+
+ using (var client = await CreateClientAsAdministrator())
+ {
+ { // post as admin
+ var res = await client.PostAsJsonAsync("timelines/t1/posts",
+ new TimelinePostCreateRequest { Content = "aaa" });
+ res.Should().HaveStatusCode(200);
+ }
+ }
+
+ using (var client = await CreateClientAs(2))
+ {
+ { // post as member
+ var res = await client.PostAsJsonAsync("timelines/t1/posts",
+ new TimelinePostCreateRequest { Content = "aaa" });
+ res.Should().HaveStatusCode(200);
+ }
+ }
+ }
+
+ [Fact]
+ public async Task Permission_Post_Delete()
+ {
+ await CreateTestTimelines();
+
+ async Task<long> CreatePost(int userNumber)
+ {
+ using var client = await CreateClientAs(userNumber);
+ var res = await client.PostAsJsonAsync($"timelines/t1/posts",
+ new TimelinePostCreateRequest { Content = "aaa" });
+ return res.Should().HaveStatusCode(200)
+ .And.HaveJsonBody<TimelinePostInfo>()
+ .Which.Id;
+ }
+
+ using (var client = await CreateClientAsUser())
+ {
+ {
+ var res = await client.PutAsync("timelines/t1/members/user2", null);
+ res.Should().HaveStatusCode(200);
+ }
+ {
+ var res = await client.PutAsync("timelines/t1/members/user3", null);
+ res.Should().HaveStatusCode(200);
+ }
+ }
+
+ { // no auth should get 401
+ using var client = await CreateDefaultClient();
+ var res = await client.DeleteAsync("timelines/t1/posts/12");
+ res.Should().HaveStatusCode(401);
+ }
+
+ { // self can delete self
+ var postId = await CreatePost(1);
+ using var client = await CreateClientAsUser();
+ var res = await client.DeleteAsync($"timelines/t1/posts/{postId}");
+ res.Should().HaveStatusCode(200);
+ }
+
+ { // admin can delete any
+ var postId = await CreatePost(1);
+ using var client = await CreateClientAsAdministrator();
+ var res = await client.DeleteAsync($"timelines/t1/posts/{postId}");
+ res.Should().HaveStatusCode(200);
+ }
+
+ { // owner can delete other
+ var postId = await CreatePost(2);
+ using var client = await CreateClientAsUser();
+ var res = await client.DeleteAsync($"timelines/t1/posts/{postId}");
+ res.Should().HaveStatusCode(200);
+ }
+
+ { // author can delete self
+ var postId = await CreatePost(2);
+ using var client = await CreateClientAs(2);
+ var res = await client.DeleteAsync($"timelines/t1/posts/{postId}");
+ res.Should().HaveStatusCode(200);
+ }
+
+ { // otherwise is forbidden
+ var postId = await CreatePost(2);
+ using var client = await CreateClientAs(3);
+ var res = await client.DeleteAsync($"timelines/t1/posts/{postId}");
+ res.Should().HaveStatusCode(403);
+ }
+ }
+
+ [Fact]
+ public async Task Post_Op_Should_Work()
+ {
+ await CreateTestTimelines();
+
+ {
+ using var client = await CreateClientAsUser();
+ {
+ var res = await client.GetAsync("timelines/t1/posts");
+ res.Should().HaveStatusCode(200)
+ .And.HaveJsonBody<TimelinePostInfo[]>()
+ .Which.Should().NotBeNull().And.BeEmpty();
+ }
+ {
+ var res = await client.PostAsJsonAsync("timelines/t1/posts",
+ new TimelinePostCreateRequest { Content = null });
+ res.Should().BeInvalidModel();
+ }
+ const string mockContent = "aaa";
+ TimelinePostInfo createRes;
+ {
+ var res = await client.PostAsJsonAsync("timelines/t1/posts",
+ new TimelinePostCreateRequest { Content = mockContent });
+ var body = res.Should().HaveStatusCode(200)
+ .And.HaveJsonBody<TimelinePostInfo>()
+ .Which;
+ body.Should().NotBeNull();
+ body.Content.Should().Be(mockContent);
+ body.Author.Should().BeEquivalentTo(UserInfos[1]);
+ createRes = body;
+ }
+ {
+ var res = await client.GetAsync("timelines/t1/posts");
+ res.Should().HaveStatusCode(200)
+ .And.HaveJsonBody<TimelinePostInfo[]>()
+ .Which.Should().NotBeNull().And.BeEquivalentTo(createRes);
+ }
+ const string mockContent2 = "bbb";
+ var mockTime2 = DateTime.Now.AddDays(-1);
+ TimelinePostInfo createRes2;
+ {
+ var res = await client.PostAsJsonAsync("timelines/t1/posts",
+ new TimelinePostCreateRequest { Content = mockContent2, Time = mockTime2 });
+ var body = res.Should().HaveStatusCode(200)
+ .And.HaveJsonBody<TimelinePostInfo>()
+ .Which;
+ body.Should().NotBeNull();
+ body.Content.Should().Be(mockContent2);
+ body.Author.Should().BeEquivalentTo(UserInfos[1]);
+ body.Time.Should().BeCloseTo(mockTime2, 1000);
+ createRes2 = body;
+ }
+ {
+ var res = await client.GetAsync("timelines/t1/posts");
+ res.Should().HaveStatusCode(200)
+ .And.HaveJsonBody<TimelinePostInfo[]>()
+ .Which.Should().NotBeNull().And.BeEquivalentTo(createRes, createRes2);
+ }
+ {
+ var res = await client.DeleteAsync($"timelines/t1/posts/{createRes.Id}");
+ res.Should().BeDelete(true);
+ }
+ {
+ var res = await client.DeleteAsync("timelines/t1/posts/30000");
+ res.Should().BeDelete(false);
+ }
+ {
+ var res = await client.GetAsync("timelines/t1/posts");
+ res.Should().HaveStatusCode(200)
+ .And.HaveJsonBody<TimelinePostInfo[]>()
+ .Which.Should().NotBeNull().And.BeEquivalentTo(createRes2);
+ }
+ }
+ }
+
+ [Fact]
+ public async Task GetPost_Should_Ordered()
+ {
+ await CreateTestTimelines();
+
+ using var client = await CreateClientAsUser();
+
+ async Task<long> CreatePost(DateTime time)
+ {
+ var res = await client.PostAsJsonAsync("timelines/t1/posts",
+ new TimelinePostCreateRequest { Content = "aaa", Time = time });
+ return res.Should().HaveStatusCode(200)
+ .And.HaveJsonBody<TimelinePostInfo>()
+ .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("timelines/t1/posts");
+ res.Should().HaveStatusCode(200)
+ .And.HaveJsonBody<TimelinePostInfo[]>()
+ .Which.Select(p => p.Id).Should().Equal(id1, id2, id0);
+ }
+ }
+ }
+}
diff --git a/Timeline/Controllers/PersonalTimelineController.cs b/Timeline/Controllers/PersonalTimelineController.cs
index 11353bb5..b6c213d9 100644
--- a/Timeline/Controllers/PersonalTimelineController.cs
+++ b/Timeline/Controllers/PersonalTimelineController.cs
@@ -26,9 +26,9 @@ namespace Timeline.Controllers
}
[HttpGet("users/{username}/timeline")]
- public async Task<ActionResult<BaseTimelineInfo>> TimelineGet([FromRoute][Username] string username)
+ public async Task<ActionResult<TimelineInfo>> TimelineGet([FromRoute][Username] string username)
{
- return await _service.GetTimeline(username);
+ return (await _service.GetTimeline(username)).FillLinksForPersonalTimeline(Url);
}
[HttpGet("users/{username}/timeline/posts")]
@@ -77,14 +77,14 @@ namespace Timeline.Controllers
[HttpPatch("users/{username}/timeline")]
[Authorize]
- public async Task<ActionResult<BaseTimelineInfo>> TimelinePatch([FromRoute][Username] string username, [FromBody] TimelinePatchRequest body)
+ public async Task<ActionResult<TimelineInfo>> TimelinePatch([FromRoute][Username] string username, [FromBody] TimelinePatchRequest body)
{
- if (!this.IsAdministrator() && !(User.Identity.Name == username))
+ if (!this.IsAdministrator() && !(await _service.HasManagePermission(username, this.GetUserId())))
{
return StatusCode(StatusCodes.Status403Forbidden, ErrorResponse.Common.Forbid());
}
await _service.ChangeProperty(username, body);
- var timeline = await _service.GetTimeline(username);
+ var timeline = (await _service.GetTimeline(username)).FillLinksForPersonalTimeline(Url);
return Ok(timeline);
}
@@ -92,7 +92,7 @@ namespace Timeline.Controllers
[Authorize]
public async Task<ActionResult> TimelineMemberPut([FromRoute][Username] string username, [FromRoute][Username] string member)
{
- if (!this.IsAdministrator() && !(User.Identity.Name == username))
+ if (!this.IsAdministrator() && !(await _service.HasManagePermission(username, this.GetUserId())))
{
return StatusCode(StatusCodes.Status403Forbidden, ErrorResponse.Common.Forbid());
}
@@ -104,7 +104,7 @@ namespace Timeline.Controllers
}
catch (UserNotExistException)
{
- return BadRequest(ErrorResponse.TimelineController.MemberPut_NotExist());
+ return BadRequest(ErrorResponse.TimelineCommon.MemberPut_NotExist());
}
}
@@ -112,7 +112,7 @@ namespace Timeline.Controllers
[Authorize]
public async Task<ActionResult> TimelineMemberDelete([FromRoute][Username] string username, [FromRoute][Username] string member)
{
- if (!this.IsAdministrator() && !(User.Identity.Name == username))
+ if (!this.IsAdministrator() && !(await _service.HasManagePermission(username, this.GetUserId())))
{
return StatusCode(StatusCodes.Status403Forbidden, ErrorResponse.Common.Forbid());
}
diff --git a/Timeline/Controllers/TimelineController.cs b/Timeline/Controllers/TimelineController.cs
new file mode 100644
index 00000000..a514ccd9
--- /dev/null
+++ b/Timeline/Controllers/TimelineController.cs
@@ -0,0 +1,149 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Timeline.Filters;
+using Timeline.Models.Http;
+using Timeline.Models.Validation;
+using Timeline.Services;
+
+namespace Timeline.Controllers
+{
+ [ApiController]
+ [CatchTimelineNotExistException]
+ public class TimelineController : Controller
+ {
+ private readonly ILogger<TimelineController> _logger;
+
+ private readonly ITimelineService _service;
+
+ public TimelineController(ILogger<TimelineController> logger, ITimelineService service)
+ {
+ _logger = logger;
+ _service = service;
+ }
+
+ [HttpGet("timelines/{name}")]
+ public async Task<ActionResult<TimelineInfo>> TimelineGet([FromRoute][TimelineName] string name)
+ {
+ var result = (await _service.GetTimeline(name)).FillLinksForNormalTimeline(Url);
+ return Ok(result);
+ }
+
+ [HttpGet("timelines/{name}/posts")]
+ public async Task<ActionResult<IList<TimelinePostInfo>>> PostListGet([FromRoute][TimelineName] string name)
+ {
+ if (!this.IsAdministrator() && !await _service.HasReadPermission(name, this.GetOptionalUserId()))
+ {
+ return StatusCode(StatusCodes.Status403Forbidden, ErrorResponse.Common.Forbid());
+ }
+
+ return await _service.GetPosts(name);
+ }
+
+ [HttpPost("timelines/{name}/posts")]
+ [Authorize]
+ public async Task<ActionResult<TimelinePostInfo>> PostPost([FromRoute][TimelineName] string name, [FromBody] TimelinePostCreateRequest body)
+ {
+ var id = this.GetUserId();
+ if (!this.IsAdministrator() && !await _service.IsMemberOf(name, id))
+ {
+ return StatusCode(StatusCodes.Status403Forbidden, ErrorResponse.Common.Forbid());
+ }
+
+ var res = await _service.CreatePost(name, id, body.Content, body.Time);
+ return res;
+ }
+
+ [HttpDelete("timelines/{name}/posts/{id}")]
+ [Authorize]
+ public async Task<ActionResult> PostDelete([FromRoute][TimelineName] string name, [FromRoute] long id)
+ {
+ try
+ {
+ if (!this.IsAdministrator() && !await _service.HasPostModifyPermission(name, id, this.GetUserId()))
+ {
+ return StatusCode(StatusCodes.Status403Forbidden, ErrorResponse.Common.Forbid());
+ }
+ await _service.DeletePost(name, id);
+ return Ok(CommonDeleteResponse.Delete());
+ }
+ catch (TimelinePostNotExistException)
+ {
+ return Ok(CommonDeleteResponse.NotExist());
+ }
+ }
+
+ [HttpPatch("timelines/{name}")]
+ [Authorize]
+ public async Task<ActionResult<TimelineInfo>> TimelinePatch([FromRoute][TimelineName] string name, [FromBody] TimelinePatchRequest body)
+ {
+ if (!this.IsAdministrator() && !(await _service.HasManagePermission(name, this.GetUserId())))
+ {
+ return StatusCode(StatusCodes.Status403Forbidden, ErrorResponse.Common.Forbid());
+ }
+ await _service.ChangeProperty(name, body);
+ var timeline = (await _service.GetTimeline(name)).FillLinksForNormalTimeline(Url);
+ return Ok(timeline);
+ }
+
+ [HttpPut("timelines/{name}/members/{member}")]
+ [Authorize]
+ public async Task<ActionResult> TimelineMemberPut([FromRoute][TimelineName] string name, [FromRoute][Username] string member)
+ {
+ if (!this.IsAdministrator() && !(await _service.HasManagePermission(name, this.GetUserId())))
+ {
+ return StatusCode(StatusCodes.Status403Forbidden, ErrorResponse.Common.Forbid());
+ }
+
+ try
+ {
+ await _service.ChangeMember(name, new List<string> { member }, null);
+ return Ok();
+ }
+ catch (UserNotExistException)
+ {
+ return BadRequest(ErrorResponse.TimelineCommon.MemberPut_NotExist());
+ }
+ }
+
+ [HttpDelete("timelines/{name}/members/{member}")]
+ [Authorize]
+ public async Task<ActionResult> TimelineMemberDelete([FromRoute][TimelineName] string name, [FromRoute][Username] string member)
+ {
+ if (!this.IsAdministrator() && !(await _service.HasManagePermission(name, this.GetUserId())))
+ {
+ return StatusCode(StatusCodes.Status403Forbidden, ErrorResponse.Common.Forbid());
+ }
+
+ try
+ {
+ await _service.ChangeMember(name, null, new List<string> { member });
+ return Ok(CommonDeleteResponse.Delete());
+ }
+ catch (UserNotExistException)
+ {
+ return Ok(CommonDeleteResponse.NotExist());
+ }
+ }
+
+ [HttpPost("timelines")]
+ [Authorize]
+ public async Task<ActionResult<TimelineInfo>> TimelineCreate([FromBody] TimelineCreateRequest body)
+ {
+ var userId = this.GetUserId();
+
+ try
+ {
+ var timelineInfo = (await _service.CreateTimeline(body.Name, userId)).FillLinksForNormalTimeline(Url);
+ return Ok(timelineInfo);
+ }
+ catch (ConflictException)
+ {
+ return BadRequest(ErrorResponse.TimelineCommon.NameConflict());
+ }
+ }
+ }
+}
diff --git a/Timeline/Entities/TimelineEntity.cs b/Timeline/Entities/TimelineEntity.cs
index c50fe6dd..0b252bab 100644
--- a/Timeline/Entities/TimelineEntity.cs
+++ b/Timeline/Entities/TimelineEntity.cs
@@ -6,7 +6,8 @@ using Timeline.Models.Http;
namespace Timeline.Entities
{
- [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "This is entity object.")]
+#pragma warning disable CA2227 // Collection properties should be read only
+ // TODO: Create index for this table.
[Table("timelines")]
public class TimelineEntity
{
@@ -38,4 +39,5 @@ namespace Timeline.Entities
public List<TimelinePostEntity> Posts { get; set; } = default!;
}
+#pragma warning restore CA2227 // Collection properties should be read only
}
diff --git a/Timeline/Filters/Timeline.cs b/Timeline/Filters/Timeline.cs
index ed78e645..e133c9d6 100644
--- a/Timeline/Filters/Timeline.cs
+++ b/Timeline/Filters/Timeline.cs
@@ -17,7 +17,7 @@ namespace Timeline.Filters
}
else
{
- throw new System.NotImplementedException();
+ context.Result = new NotFoundObjectResult(ErrorResponse.TimelineCommon.NotExist());
}
}
}
diff --git a/Timeline/Models/Http/ErrorResponse.cs b/Timeline/Models/Http/ErrorResponse.cs
index 87516638..0d23fe59 100644
--- a/Timeline/Models/Http/ErrorResponse.cs
+++ b/Timeline/Models/Http/ErrorResponse.cs
@@ -1,4 +1,5 @@
-using static Timeline.Resources.Messages;
+
+using static Timeline.Resources.Messages;
namespace Timeline.Models.Http
{
@@ -241,21 +242,41 @@ namespace Timeline.Models.Http
}
- public static class TimelineController
+ public static class TimelineCommon
{
+ public static CommonResponse NameConflict(params object?[] formatArgs)
+ {
+ return new CommonResponse(ErrorCodes.TimelineCommon.NameConflict, string.Format(TimelineCommon_NameConflict, formatArgs));
+ }
+
+ public static CommonResponse CustomMessage_NameConflict(string message, params object?[] formatArgs)
+ {
+ return new CommonResponse(ErrorCodes.TimelineCommon.NameConflict, string.Format(message, formatArgs));
+ }
+
+ public static CommonResponse NotExist(params object?[] formatArgs)
+ {
+ return new CommonResponse(ErrorCodes.TimelineCommon.NotExist, string.Format(TimelineCommon_NotExist, formatArgs));
+ }
+
+ public static CommonResponse CustomMessage_NotExist(string message, params object?[] formatArgs)
+ {
+ return new CommonResponse(ErrorCodes.TimelineCommon.NotExist, string.Format(message, formatArgs));
+ }
+
public static CommonResponse MemberPut_NotExist(params object?[] formatArgs)
{
- return new CommonResponse(ErrorCodes.TimelineController.MemberPut_NotExist, string.Format(TimelineController_MemberPut_NotExist, formatArgs));
+ return new CommonResponse(ErrorCodes.TimelineCommon.MemberPut_NotExist, string.Format(TimelineCommon_MemberPut_NotExist, formatArgs));
}
public static CommonResponse CustomMessage_MemberPut_NotExist(string message, params object?[] formatArgs)
{
- return new CommonResponse(ErrorCodes.TimelineController.MemberPut_NotExist, string.Format(message, formatArgs));
+ return new CommonResponse(ErrorCodes.TimelineCommon.MemberPut_NotExist, string.Format(message, formatArgs));
}
}
}
-} \ No newline at end of file
+}
diff --git a/Timeline/Models/Http/TimelineCommon.cs b/Timeline/Models/Http/TimelineCommon.cs
index febb8186..1cb47dac 100644
--- a/Timeline/Models/Http/TimelineCommon.cs
+++ b/Timeline/Models/Http/TimelineCommon.cs
@@ -1,5 +1,7 @@
-using System;
+using Microsoft.AspNetCore.Mvc;
+using System;
using System.Collections.Generic;
+using Timeline.Controllers;
namespace Timeline.Models.Http
{
@@ -28,17 +30,56 @@ namespace Timeline.Models.Http
public DateTime LastUpdated { 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 class TimelineInfo
{
+ public string? Name { get; set; }
public string Description { get; set; } = default!;
public UserInfo Owner { get; set; } = default!;
public TimelineVisibility Visibility { get; set; }
+#pragma warning disable CA2227 // Collection properties should be read only
public List<UserInfo> Members { get; set; } = default!;
+#pragma warning restore CA2227 // Collection properties should be read only
+
+#pragma warning disable CA1707 // Identifiers should not contain underscores
+ public TimelineInfoLinks? _links { get; set; }
+#pragma warning restore CA1707 // Identifiers should not contain underscores
}
- public class TimelineInfo : BaseTimelineInfo
+ public class TimelineInfoLinks
{
- public string Name { get; set; } = default!;
+ public string Posts { get; set; } = default!;
+ }
+
+ public static class TimelineInfoExtensions
+ {
+ public static TimelineInfo FillLinksForPersonalTimeline(this TimelineInfo info, IUrlHelper urlHelper)
+ {
+ if (info == null)
+ throw new ArgumentNullException(nameof(info));
+ if (urlHelper == null)
+ throw new ArgumentNullException(nameof(urlHelper));
+
+ info._links = new TimelineInfoLinks
+ {
+ Posts = urlHelper.ActionLink(nameof(PersonalTimelineController.PostListGet), nameof(PersonalTimelineController)[0..^nameof(Controller).Length], new { info.Owner.Username })
+ };
+
+ return info;
+ }
+
+ public static TimelineInfo FillLinksForNormalTimeline(this TimelineInfo info, IUrlHelper urlHelper)
+ {
+ if (info == null)
+ throw new ArgumentNullException(nameof(info));
+ if (urlHelper == null)
+ throw new ArgumentNullException(nameof(urlHelper));
+
+ info._links = new TimelineInfoLinks
+ {
+ Posts = urlHelper.ActionLink(nameof(TimelineController.PostListGet), nameof(TimelineController)[0..^nameof(Controller).Length], new { info.Name })
+ };
+
+ return info;
+ }
}
}
diff --git a/Timeline/Models/Http/TimelineController.cs b/Timeline/Models/Http/TimelineController.cs
index f9a4d3e5..6d461bb9 100644
--- a/Timeline/Models/Http/TimelineController.cs
+++ b/Timeline/Models/Http/TimelineController.cs
@@ -1,5 +1,6 @@
using System;
using System.ComponentModel.DataAnnotations;
+using Timeline.Models.Validation;
namespace Timeline.Models.Http
{
@@ -11,6 +12,13 @@ namespace Timeline.Models.Http
public DateTime? Time { get; set; }
}
+ public class TimelineCreateRequest
+ {
+ [Required]
+ [TimelineName]
+ public string Name { get; set; } = default!;
+ }
+
public class TimelinePatchRequest
{
public string? Description { get; set; }
diff --git a/Timeline/Models/Validation/NameValidator.cs b/Timeline/Models/Validation/NameValidator.cs
new file mode 100644
index 00000000..8db10ebd
--- /dev/null
+++ b/Timeline/Models/Validation/NameValidator.cs
@@ -0,0 +1,33 @@
+using System.Linq;
+using static Timeline.Resources.Models.Validation.NameValidator;
+
+namespace Timeline.Models.Validation
+{
+ public class NameValidator : Validator<string>
+ {
+ public const int MaxLength = 26;
+
+ protected override (bool, string) DoValidate(string value)
+ {
+ if (value.Length == 0)
+ {
+ return (false, MessageEmptyString);
+ }
+
+ if (value.Length > 26)
+ {
+ return (false, MessageTooLong);
+ }
+
+ foreach ((char c, int i) in value.Select((c, i) => (c, i)))
+ {
+ if (!(char.IsLetterOrDigit(c) || c == '-' || c == '_'))
+ {
+ return (false, MessageInvalidChar);
+ }
+ }
+
+ return (true, GetSuccessMessage());
+ }
+ }
+}
diff --git a/Timeline/Models/Validation/TimelineNameValidator.cs b/Timeline/Models/Validation/TimelineNameValidator.cs
new file mode 100644
index 00000000..f1ab54e8
--- /dev/null
+++ b/Timeline/Models/Validation/TimelineNameValidator.cs
@@ -0,0 +1,19 @@
+using System;
+
+namespace Timeline.Models.Validation
+{
+ public class TimelineNameValidator : NameValidator
+ {
+ }
+
+ [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter,
+ AllowMultiple = false)]
+ public class TimelineNameAttribute : ValidateWithAttribute
+ {
+ public TimelineNameAttribute()
+ : base(typeof(TimelineNameValidator))
+ {
+
+ }
+ }
+}
diff --git a/Timeline/Models/Validation/UsernameValidator.cs b/Timeline/Models/Validation/UsernameValidator.cs
index d8f3bdc0..87bbf85f 100644
--- a/Timeline/Models/Validation/UsernameValidator.cs
+++ b/Timeline/Models/Validation/UsernameValidator.cs
@@ -1,35 +1,9 @@
using System;
-using System.Linq;
-using static Timeline.Resources.Models.Validation.UsernameValidator;
namespace Timeline.Models.Validation
{
- public class UsernameValidator : Validator<string>
+ public class UsernameValidator : NameValidator
{
- public const int MaxLength = 26;
-
- protected override (bool, string) DoValidate(string value)
- {
- if (value.Length == 0)
- {
- return (false, MessageEmptyString);
- }
-
- if (value.Length > 26)
- {
- return (false, MessageTooLong);
- }
-
- foreach ((char c, int i) in value.Select((c, i) => (c, i)))
- {
- if (!(char.IsLetterOrDigit(c) || c == '-' || c == '_'))
- {
- return (false, MessageInvalidChar);
- }
- }
-
- return (true, GetSuccessMessage());
- }
}
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter,
diff --git a/Timeline/Resources/Messages.Designer.cs b/Timeline/Resources/Messages.Designer.cs
index 332c8817..eeb44f10 100644
--- a/Timeline/Resources/Messages.Designer.cs
+++ b/Timeline/Resources/Messages.Designer.cs
@@ -151,38 +151,29 @@ namespace Timeline.Resources {
}
/// <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 TimelineController_ChangeMember_UsernameBadFormat {
- get {
- return ResourceManager.GetString("TimelineController_ChangeMember_UsernameBadFormat", resourceCulture);
- }
- }
-
- /// <summary>
- /// Looks up a localized string similar to The {0}-st user to do operation {1} on does not exist..
+ /// Looks up a localized string similar to The user to set as member does not exist..
/// </summary>
- internal static string TimelineController_ChangeMember_UserNotExist {
+ internal static string TimelineCommon_MemberPut_NotExist {
get {
- return ResourceManager.GetString("TimelineController_ChangeMember_UserNotExist", resourceCulture);
+ return ResourceManager.GetString("TimelineCommon_MemberPut_NotExist", resourceCulture);
}
}
/// <summary>
- /// Looks up a localized string similar to The user to set as member does not exist..
+ /// Looks up a localized string similar to A timeline with given name already exists..
/// </summary>
- internal static string TimelineController_MemberPut_NotExist {
+ internal static string TimelineCommon_NameConflict {
get {
- return ResourceManager.GetString("TimelineController_MemberPut_NotExist", resourceCulture);
+ return ResourceManager.GetString("TimelineCommon_NameConflict", resourceCulture);
}
}
/// <summary>
- /// Looks up a localized string similar to The post to delete does not exist..
+ /// Looks up a localized string similar to The timeline with given name does not exist..
/// </summary>
- internal static string TimelineController_PostOperationDelete_NotExist {
+ internal static string TimelineCommon_NotExist {
get {
- return ResourceManager.GetString("TimelineController_PostOperationDelete_NotExist", resourceCulture);
+ return ResourceManager.GetString("TimelineCommon_NotExist", resourceCulture);
}
}
diff --git a/Timeline/Resources/Messages.resx b/Timeline/Resources/Messages.resx
index cb6c3891..66a84d5f 100644
--- a/Timeline/Resources/Messages.resx
+++ b/Timeline/Resources/Messages.resx
@@ -147,17 +147,14 @@
<data name="Common_InvalidModel" xml:space="preserve">
<value>Model is of bad format.</value>
</data>
- <data name="TimelineController_ChangeMember_UsernameBadFormat" xml:space="preserve">
- <value>The {0}-st username to do operation {1} on is of bad format.</value>
- </data>
- <data name="TimelineController_ChangeMember_UserNotExist" xml:space="preserve">
- <value>The {0}-st user to do operation {1} on does not exist.</value>
- </data>
- <data name="TimelineController_MemberPut_NotExist" xml:space="preserve">
+ <data name="TimelineCommon_MemberPut_NotExist" xml:space="preserve">
<value>The user to set as member does not exist.</value>
</data>
- <data name="TimelineController_PostOperationDelete_NotExist" xml:space="preserve">
- <value>The post to delete does not exist.</value>
+ <data name="TimelineCommon_NameConflict" xml:space="preserve">
+ <value>A timeline with given name already exists.</value>
+ </data>
+ <data name="TimelineCommon_NotExist" xml:space="preserve">
+ <value>The timeline with given name does not exist.</value>
</data>
<data name="TokenController_Create_BadCredential" xml:space="preserve">
<value>Username or password is invalid.</value>
diff --git a/Timeline/Resources/Models/Validation/UsernameValidator.Designer.cs b/Timeline/Resources/Models/Validation/NameValidator.Designer.cs
index ac925504..5b869226 100644
--- a/Timeline/Resources/Models/Validation/UsernameValidator.Designer.cs
+++ b/Timeline/Resources/Models/Validation/NameValidator.Designer.cs
@@ -22,14 +22,14 @@ namespace Timeline.Resources.Models.Validation {
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
- internal class UsernameValidator {
+ internal class NameValidator {
private static global::System.Resources.ResourceManager resourceMan;
private static global::System.Globalization.CultureInfo resourceCulture;
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
- internal UsernameValidator() {
+ internal NameValidator() {
}
/// <summary>
@@ -39,7 +39,7 @@ namespace Timeline.Resources.Models.Validation {
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.Models.Validation.UsernameValidator", typeof(UsernameValidator).Assembly);
+ global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Timeline.Resources.Models.Validation.NameValidator", typeof(NameValidator).Assembly);
resourceMan = temp;
}
return resourceMan;
diff --git a/Timeline/Resources/Models/Validation/UsernameValidator.resx b/Timeline/Resources/Models/Validation/NameValidator.resx
index 08a814d0..08a814d0 100644
--- a/Timeline/Resources/Models/Validation/UsernameValidator.resx
+++ b/Timeline/Resources/Models/Validation/NameValidator.resx
diff --git a/Timeline/Resources/Services/TimelineService.Designer.cs b/Timeline/Resources/Services/TimelineService.Designer.cs
index 8212c252..3ee5959f 100644
--- a/Timeline/Resources/Services/TimelineService.Designer.cs
+++ b/Timeline/Resources/Services/TimelineService.Designer.cs
@@ -77,5 +77,23 @@ namespace Timeline.Resources.Services {
return ResourceManager.GetString("ExceptionFindTimelineUsernameBadFormat", resourceCulture);
}
}
+
+ /// <summary>
+ /// Looks up a localized string similar to The timeline name is of bad format because {0}..
+ /// </summary>
+ internal static string ExceptionTimelineNameBadFormat {
+ get {
+ return ResourceManager.GetString("ExceptionTimelineNameBadFormat", resourceCulture);
+ }
+ }
+
+ /// <summary>
+ /// Looks up a localized string similar to The timeline with given name already exists..
+ /// </summary>
+ internal static string ExceptionTimelineNameConflict {
+ get {
+ return ResourceManager.GetString("ExceptionTimelineNameConflict", resourceCulture);
+ }
+ }
}
}
diff --git a/Timeline/Resources/Services/TimelineService.resx b/Timeline/Resources/Services/TimelineService.resx
index 0429a2f8..e0d76c9a 100644
--- a/Timeline/Resources/Services/TimelineService.resx
+++ b/Timeline/Resources/Services/TimelineService.resx
@@ -123,4 +123,10 @@
<data name="ExceptionFindTimelineUsernameBadFormat" xml:space="preserve">
<value>The owner username of personal timeline is of bad format.</value>
</data>
+ <data name="ExceptionTimelineNameBadFormat" xml:space="preserve">
+ <value>The timeline name is of bad format because {0}.</value>
+ </data>
+ <data name="ExceptionTimelineNameConflict" xml:space="preserve">
+ <value>The timeline with given name already exists.</value>
+ </data>
</root> \ No newline at end of file
diff --git a/Timeline/Services/TimelineService.cs b/Timeline/Services/TimelineService.cs
index 0ea68265..6f9d437e 100644
--- a/Timeline/Services/TimelineService.cs
+++ b/Timeline/Services/TimelineService.cs
@@ -25,6 +25,37 @@ namespace Timeline.Services
public interface IBaseTimelineService
{
/// <summary>
+ /// Get the timeline info.
+ /// </summary>
+ /// <param name="name">Username or the timeline name. See remarks of <see cref="IBaseTimelineService"/>.</param>
+ /// <returns>The timeline info.</returns>
+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="name"/> is null.</exception>
+ /// <exception cref="ArgumentException">Thrown when <paramref name="name"/> is illegal. It is not a valid timeline name (for normal timeline service) or a valid username (for personal timeline service).</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<TimelineInfo> GetTimeline(string name);
+
+ /// <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>
+ /// <returns>The timeline info.</returns>
+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="name"/> is null.</exception>
+ /// <exception cref="ArgumentException">Thrown when <paramref name="name"/> is illegal. It is not a valid timeline name (for normal timeline service) or a valid username (for personal timeline service).</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, TimelinePatchRequest newProperties);
+
+ /// <summary>
/// Get all the posts in the timeline.
/// </summary>
/// <param name="name">Username or the timeline name. See remarks of <see cref="IBaseTimelineService"/>.</param>
@@ -108,6 +139,26 @@ namespace Timeline.Services
Task ChangeMember(string name, IList<string>? add, IList<string>? remove);
/// <summary>
+ /// Check whether a user can manage(change timeline info, member, ...) a timeline.
+ /// </summary>
+ /// <param name="name"></param>
+ /// <param name="id"></param>
+ /// <returns>True if the user can manage the timeline, otherwise false.</returns>
+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="name"/> is null.</exception>
+ /// <exception cref="ArgumentException">Thrown when <paramref name="name"/> is illegal. It is not a valid timeline name (for normal timeline service) or a valid username (for personal timeline service).</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 method does not check whether visitor is administrator.
+ /// Return false if user with user id does not exist.
+ /// </remarks>
+ Task<bool> HasManagePermission(string name, long userId);
+
+ /// <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>
@@ -178,64 +229,20 @@ namespace Timeline.Services
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="ArgumentException">
- /// 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 id of owner of the timeline.</param>
- /// <exception cref="ArgumentNullException">Thrown when <paramref name="name"/> or <paramref name="owner"/> is null.</exception>
- /// <exception cref="ArgumentException">Thrown when timeline name is invalid. Currently it means it is an empty string.</exception>
+ /// <returns>The info of the new timeline.</returns>
+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="name"/> is null.</exception>
+ /// <exception cref="ArgumentException">Thrown when timeline name is invalid.</exception>
/// <exception cref="ConflictException">Thrown when the timeline already exists.</exception>
/// <exception cref="UserNotExistException">Thrown when the owner user does not exist.</exception>
- Task CreateTimeline(string name, long owner);
+ Task<TimelineInfo> CreateTimeline(string name, long 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="ArgumentException">
- /// Thrown when <paramref name="username"/> is of bad format.
- /// </exception>
- /// <exception cref="TimelineNotExistException">
- /// Thrown when the user does not exist. Inner exception MUST be <see cref="UserNotExistException"/>.
- /// </exception>
- Task<BaseTimelineInfo> GetTimeline(string username);
-
- /// <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="username"/> is null.
- /// </exception>
- /// <exception cref="ArgumentException">
- /// Thrown when <paramref name="username"/> is of bad format.
- /// </exception>
- /// <exception cref="TimelineNotExistException">
- /// Thrown when the user does not exist. Inner exception MUST be <see cref="UserNotExistException"/>.
- /// </exception>
- Task ChangeProperty(string name, TimelinePatchRequest newProperties);
}
@@ -259,6 +266,17 @@ namespace Timeline.Services
protected IMapper Mapper { get; }
+ protected TimelineEntity CreateNewEntity(string? name, long owner)
+ {
+ return new TimelineEntity
+ {
+ Name = name,
+ OwnerId = owner,
+ Visibility = TimelineVisibility.Register,
+ CreateTime = Clock.GetCurrentTime()
+ };
+ }
+
/// <summary>
/// Find the timeline id by the name.
/// For details, see remarks.
@@ -283,6 +301,35 @@ namespace Timeline.Services
/// </remarks>
protected abstract Task<long> FindTimelineId(string name);
+ public async Task<TimelineInfo> GetTimeline(string name)
+ {
+ if (name == null)
+ throw new ArgumentNullException(nameof(name));
+
+ var timelineId = await FindTimelineId(name);
+
+ 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 owner = Mapper.Map<UserInfo>(await UserService.GetUserById(timelineEntity.OwnerId));
+
+ var members = new List<UserInfo>();
+ foreach (var memberEntity in timelineMemberEntities)
+ {
+ members.Add(Mapper.Map<UserInfo>(await UserService.GetUserById(memberEntity.UserId)));
+ }
+
+ return new TimelineInfo
+ {
+ Name = timelineEntity.Name,
+ Description = timelineEntity.Description ?? "",
+ Owner = owner,
+ Visibility = timelineEntity.Visibility,
+ Members = members
+ };
+ }
+
public async Task<List<TimelinePostInfo>> GetPosts(string name)
{
if (name == null)
@@ -464,6 +511,17 @@ namespace Timeline.Services
await Database.SaveChangesAsync();
}
+ public async Task<bool> HasManagePermission(string name, long userId)
+ {
+ if (name == null)
+ throw new ArgumentNullException(nameof(name));
+
+ var timelineId = await FindTimelineId(name);
+ var timelineEntity = await Database.Timelines.Where(t => t.Id == timelineId).Select(t => new { t.OwnerId }).SingleAsync();
+
+ return userId == timelineEntity.OwnerId;
+ }
+
public async Task<bool> HasReadPermission(string name, long? visitorId)
{
if (name == null)
@@ -523,6 +581,72 @@ namespace Timeline.Services
}
}
+ public class TimelineService : BaseTimelineService, ITimelineService
+ {
+ private readonly TimelineNameValidator _timelineNameValidator = new TimelineNameValidator();
+
+ private void ValidateTimelineName(string name, string paramName)
+ {
+ if (!_timelineNameValidator.Validate(name, out var message))
+ {
+ throw new ArgumentException(string.Format(CultureInfo.CurrentCulture, ExceptionTimelineNameBadFormat, message), paramName);
+ }
+ }
+
+ public TimelineService(ILoggerFactory loggerFactory, DatabaseContext database, IUserService userService, IMapper mapper, IClock clock)
+ : base(loggerFactory, database, userService, mapper, clock)
+ {
+
+ }
+
+ protected override async Task<long> FindTimelineId(string name)
+ {
+ if (name == null)
+ throw new ArgumentNullException(nameof(name));
+
+ ValidateTimelineName(name, nameof(name));
+
+ var timelineEntity = await Database.Timelines.Where(t => t.Name == name).Select(t => new { t.Id }).SingleOrDefaultAsync();
+
+ if (timelineEntity == null)
+ {
+ throw new TimelineNotExistException(name);
+ }
+ else
+ {
+ return timelineEntity.Id;
+ }
+ }
+
+ public async Task<TimelineInfo> CreateTimeline(string name, long owner)
+ {
+ if (name == null)
+ throw new ArgumentNullException(nameof(name));
+
+ ValidateTimelineName(name, nameof(name));
+
+ var user = await UserService.GetUserById(owner);
+
+ var conflict = await Database.Timelines.AnyAsync(t => t.Name == name);
+
+ if (conflict)
+ throw new ConflictException(ExceptionTimelineNameConflict);
+
+ var newEntity = CreateNewEntity(name, owner);
+ Database.Timelines.Add(newEntity);
+ await Database.SaveChangesAsync();
+
+ return new TimelineInfo
+ {
+ Name = name,
+ Description = "",
+ Owner = Mapper.Map<UserInfo>(user),
+ Visibility = newEntity.Visibility,
+ Members = new List<UserInfo>()
+ };
+ }
+ }
+
public class PersonalTimelineService : BaseTimelineService, IPersonalTimelineService
{
public PersonalTimelineService(ILoggerFactory loggerFactory, DatabaseContext database, IUserService userService, IMapper mapper, IClock clock)
@@ -533,6 +657,9 @@ namespace Timeline.Services
protected override async Task<long> FindTimelineId(string name)
{
+ if (name == null)
+ throw new ArgumentNullException(nameof(name));
+
long userId;
try
{
@@ -555,48 +682,12 @@ namespace Timeline.Services
}
else
{
- var newTimelineEntity = new TimelineEntity
- {
- Name = null,
- Description = null,
- OwnerId = userId,
- Visibility = TimelineVisibility.Register,
- CreateTime = Clock.GetCurrentTime(),
- };
+ var newTimelineEntity = CreateNewEntity(null, userId);
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 owner = Mapper.Map<UserInfo>(await UserService.GetUserById(timelineEntity.OwnerId));
-
- var members = new List<UserInfo>();
- foreach (var memberEntity in timelineMemberEntities)
- {
- members.Add(Mapper.Map<UserInfo>(await UserService.GetUserById(memberEntity.UserId)));
- }
-
- return new BaseTimelineInfo
- {
- Description = timelineEntity.Description ?? "",
- Owner = owner,
- Visibility = timelineEntity.Visibility,
- Members = members
- };
- }
-
}
}
diff --git a/Timeline/Startup.cs b/Timeline/Startup.cs
index 86349a27..2640a061 100644
--- a/Timeline/Startup.cs
+++ b/Timeline/Startup.cs
@@ -89,6 +89,7 @@ namespace Timeline
services.AddScoped<IUserTokenManager, UserTokenManager>();
services.AddUserAvatarService();
+ services.AddScoped<ITimelineService, TimelineService>();
services.AddScoped<IPersonalTimelineService, PersonalTimelineService>();
services.TryAddSingleton<IActionContextAccessor, ActionContextAccessor>();
diff --git a/Timeline/Timeline.csproj b/Timeline/Timeline.csproj
index 1a3a07cd..08999f82 100644
--- a/Timeline/Timeline.csproj
+++ b/Timeline/Timeline.csproj
@@ -89,10 +89,10 @@
<AutoGen>True</AutoGen>
<DependentUpon>NicknameValidator.resx</DependentUpon>
</Compile>
- <Compile Update="Resources\Models\Validation\UsernameValidator.Designer.cs">
+ <Compile Update="Resources\Models\Validation\NameValidator.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
- <DependentUpon>UsernameValidator.resx</DependentUpon>
+ <DependentUpon>NameValidator.resx</DependentUpon>
</Compile>
<Compile Update="Resources\Models\Validation\Validator.Designer.cs">
<DesignTime>True</DesignTime>
@@ -163,9 +163,9 @@
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>NicknameValidator.Designer.cs</LastGenOutput>
</EmbeddedResource>
- <EmbeddedResource Update="Resources\Models\Validation\UsernameValidator.resx">
+ <EmbeddedResource Update="Resources\Models\Validation\NameValidator.resx">
<Generator>ResXFileCodeGenerator</Generator>
- <LastGenOutput>UsernameValidator.Designer.cs</LastGenOutput>
+ <LastGenOutput>NameValidator.Designer.cs</LastGenOutput>
</EmbeddedResource>
<EmbeddedResource Update="Resources\Models\Validation\Validator.resx">
<Generator>ResXFileCodeGenerator</Generator>