aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Timeline.Tests/Helpers/TestClock.cs2
-rw-r--r--Timeline.Tests/IntegratedTests/TimelineTest.cs98
-rw-r--r--Timeline.Tests/Services/TimelineServiceTest.cs40
-rw-r--r--Timeline/Controllers/TimelineController.cs10
-rw-r--r--Timeline/Resources/Services/TimelineService.Designer.cs9
-rw-r--r--Timeline/Resources/Services/TimelineService.resx3
-rw-r--r--Timeline/Services/TimelineService.cs77
7 files changed, 189 insertions, 50 deletions
diff --git a/Timeline.Tests/Helpers/TestClock.cs b/Timeline.Tests/Helpers/TestClock.cs
index de7d0eb7..0cbf236d 100644
--- a/Timeline.Tests/Helpers/TestClock.cs
+++ b/Timeline.Tests/Helpers/TestClock.cs
@@ -36,7 +36,7 @@ namespace Timeline.Tests.Helpers
{
if (_currentTime == null)
return SetMockCurrentTime();
- _currentTime.Value.Add(timeSpan);
+ _currentTime += timeSpan;
return _currentTime.Value;
}
}
diff --git a/Timeline.Tests/IntegratedTests/TimelineTest.cs b/Timeline.Tests/IntegratedTests/TimelineTest.cs
index 6736fecd..4f21d8d1 100644
--- a/Timeline.Tests/IntegratedTests/TimelineTest.cs
+++ b/Timeline.Tests/IntegratedTests/TimelineTest.cs
@@ -5,9 +5,11 @@ using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Png;
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.Linq;
using System.Net;
using System.Net.Http;
+using System.Text;
using System.Threading.Tasks;
using Timeline.Entities;
using Timeline.Models;
@@ -68,17 +70,43 @@ namespace Timeline.Tests.IntegratedTests
}
}
- private static string GeneratePersonalTimelineUrl(int id, string subpath = null)
+ private static string CalculateUrlTail(string subpath, ICollection<KeyValuePair<string, string>> query)
{
- return $"timelines/@{(id == 0 ? "admin" : ("user" + id))}/{(subpath ?? "")}";
+ StringBuilder result = new StringBuilder();
+ if (subpath != null)
+ {
+ if (!subpath.StartsWith("/", StringComparison.OrdinalIgnoreCase))
+ result.Append("/");
+ result.Append(subpath);
+ }
+
+ if (query != null && query.Count != 0)
+ {
+ result.Append("?");
+ foreach (var (key, value, index) in query.Select((pair, index) => (pair.Key, pair.Value, index)))
+ {
+ result.Append(WebUtility.UrlEncode(key));
+ result.Append("=");
+ result.Append(WebUtility.UrlEncode(value));
+ if (index != query.Count - 1)
+ result.Append("&");
+ }
+ }
+
+ return result.ToString();
}
- private static string GenerateOrdinaryTimelineUrl(int id, string subpath = null)
+ private static string GeneratePersonalTimelineUrl(int id, string subpath = null, ICollection<KeyValuePair<string, string>> query = null)
{
- return $"timelines/t{id}/{(subpath ?? "")}";
+ return $"timelines/@{(id == 0 ? "admin" : ("user" + id))}{CalculateUrlTail(subpath, query)}";
}
- public delegate string TimelineUrlGenerator(int userId, string subpath = null);
+ private static string GenerateOrdinaryTimelineUrl(int id, string subpath = null, ICollection<KeyValuePair<string, string>> query = null)
+ {
+ return $"timelines/t{id}{CalculateUrlTail(subpath, query)}";
+ }
+
+ public delegate string TimelineUrlGenerator(int userId, string subpath = null, ICollection<KeyValuePair<string, string>> query = null);
public static IEnumerable<object[]> TimelineUrlGeneratorData()
{
@@ -88,12 +116,12 @@ namespace Timeline.Tests.IntegratedTests
private static string GeneratePersonalTimelineUrlByName(string name, string subpath = null)
{
- return $"timelines/@{name}/{(subpath ?? "")}";
+ return $"timelines/@{name}{(subpath == null ? "" : "/" + subpath)}";
}
private static string GenerateOrdinaryTimelineUrlByName(string name, string subpath = null)
{
- return $"timelines/{name}/{(subpath ?? "")}";
+ return $"timelines/{name}{(subpath == null ? "" : "/" + subpath)}";
}
public static IEnumerable<object[]> TimelineUrlByNameGeneratorData()
@@ -632,19 +660,20 @@ namespace Timeline.Tests.IntegratedTests
await AssertEmptyMembers();
}
- [Theory]
- [InlineData(nameof(GenerateOrdinaryTimelineUrl), -1, 200, 401, 401, 401, 401)]
- [InlineData(nameof(GenerateOrdinaryTimelineUrl), 1, 200, 200, 403, 200, 403)]
- [InlineData(nameof(GenerateOrdinaryTimelineUrl), 0, 200, 200, 200, 200, 200)]
- [InlineData(nameof(GeneratePersonalTimelineUrl), -1, 200, 401, 401, 401, 401)]
- [InlineData(nameof(GeneratePersonalTimelineUrl), 1, 200, 200, 403, 200, 403)]
- [InlineData(nameof(GeneratePersonalTimelineUrl), 0, 200, 200, 200, 200, 200)]
-
- public async Task Permission_Timeline(string generatorName, int userNumber, int get, int opPatchUser, int opPatchAdmin, int opMemberUser, int opMemberAdmin)
+ public static IEnumerable<object[]> Permission_Timeline_Data()
{
- var method = GetType().GetMethod(generatorName, System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic);
- Func<int, string, string> generator = (int id, string subpath) => (string)method.Invoke(null, new object[] { id, subpath });
+ yield return new object[] { new TimelineUrlGenerator(GenerateOrdinaryTimelineUrl), -1, 200, 401, 401, 401, 401 };
+ yield return new object[] { new TimelineUrlGenerator(GenerateOrdinaryTimelineUrl), 1, 200, 200, 403, 200, 403 };
+ yield return new object[] { new TimelineUrlGenerator(GenerateOrdinaryTimelineUrl), 0, 200, 200, 200, 200, 200 };
+ yield return new object[] { new TimelineUrlGenerator(GeneratePersonalTimelineUrl), -1, 200, 401, 401, 401, 401 };
+ yield return new object[] { new TimelineUrlGenerator(GeneratePersonalTimelineUrl), 1, 200, 200, 403, 200, 403 };
+ yield return new object[] { new TimelineUrlGenerator(GeneratePersonalTimelineUrl), 0, 200, 200, 200, 200, 200 };
+ }
+ [Theory]
+ [MemberData(nameof(Permission_Timeline_Data))]
+ public async Task Permission_Timeline(TimelineUrlGenerator generator, int userNumber, int get, int opPatchUser, int opPatchAdmin, int opMemberUser, int opMemberAdmin)
+ {
using var client = await CreateClientAs(userNumber);
{
var res = await client.GetAsync("timelines/t1");
@@ -1150,7 +1179,7 @@ namespace Timeline.Tests.IntegratedTests
[Theory]
[MemberData(nameof(TimelineUrlGeneratorData))]
- public async Task LastModified(TimelineUrlGenerator generator)
+ public async Task Timeline_LastModified(TimelineUrlGenerator generator)
{
using var client = await CreateClientAsUser();
@@ -1193,5 +1222,36 @@ namespace Timeline.Tests.IntegratedTests
.Which.LastModified.Should().BeAfter(lastModified);
}
}
+
+ [Theory]
+ [MemberData(nameof(TimelineUrlGeneratorData))]
+ public async Task Post_ModifiedSince(TimelineUrlGenerator generator)
+ {
+ using var client = await CreateClientAsUser();
+
+ DateTime testPoint = new DateTime();
+ var postContentList = new List<string> { "a", "b", "c", "d" };
+
+ foreach (var (content, index) in postContentList.Select((v, i) => (v, i)))
+ {
+ var res = await client.PostAsJsonAsync(generator(1, "posts"),
+ new TimelinePostCreateRequest { Content = new TimelinePostCreateRequestContent { Text = content, Type = TimelinePostContentTypes.Text } });
+ var post = res.Should().HaveStatusCode(200)
+ .And.HaveJsonBody<TimelinePostInfo>().Which;
+ if (index == 1)
+ testPoint = post.LastUpdated;
+ await Task.Delay(1000);
+ }
+
+ {
+
+ var res = await client.GetAsync(generator(1, "posts",
+ new Dictionary<string, string> { { "modifiedSince", testPoint.ToString("s", CultureInfo.InvariantCulture) } }));
+ res.Should().HaveStatusCode(200)
+ .And.HaveJsonBody<List<TimelinePostInfo>>()
+ .Which.Should().HaveCount(3)
+ .And.Subject.Select(p => p.Content.Text).Should().Equal(postContentList.Skip(1));
+ }
+ }
}
}
diff --git a/Timeline.Tests/Services/TimelineServiceTest.cs b/Timeline.Tests/Services/TimelineServiceTest.cs
index cb2ade61..7e7242a2 100644
--- a/Timeline.Tests/Services/TimelineServiceTest.cs
+++ b/Timeline.Tests/Services/TimelineServiceTest.cs
@@ -1,6 +1,4 @@
-using Castle.Core.Logging;
-using FluentAssertions;
-using FluentAssertions.Xml;
+using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using System;
using System.Collections.Generic;
@@ -16,7 +14,7 @@ namespace Timeline.Tests.Services
{
public class TimelineServiceTest : IAsyncLifetime, IDisposable
{
- private TestDatabase _testDatabase = new TestDatabase();
+ private readonly TestDatabase _testDatabase = new TestDatabase();
private DatabaseContext _databaseContext;
@@ -63,11 +61,11 @@ namespace Timeline.Tests.Services
[InlineData("tl")]
public async Task Timeline_LastModified(string timelineName)
{
- _clock.ForwardCurrentTime();
+ var initTime = _clock.ForwardCurrentTime();
void Check(Models.Timeline timeline)
{
- timeline.NameLastModified.Should().Be(_clock.GetCurrentTime());
+ timeline.NameLastModified.Should().Be(initTime);
timeline.LastModified.Should().Be(_clock.GetCurrentTime());
}
@@ -90,5 +88,35 @@ namespace Timeline.Tests.Services
await _timelineService.ChangeMember(timelineName, new List<string> { "admin" }, null);
await GetAndCheck();
}
+
+ [Theory]
+ [InlineData("@user")]
+ [InlineData("tl")]
+ public async Task GetPosts_ModifiedSince(string timelineName)
+ {
+ _clock.ForwardCurrentTime();
+
+ var userId = await _userService.GetUserIdByUsername("user");
+
+ var _ = TimelineHelper.ExtractTimelineName(timelineName, out var isPersonal);
+ if (!isPersonal)
+ await _timelineService.CreateTimeline(timelineName, userId);
+
+ var postContentList = new string[] { "a", "b", "c", "d" };
+
+ DateTime testPoint = new DateTime();
+
+ foreach (var (content, index) in postContentList.Select((v, i) => (v, i)))
+ {
+ var t = _clock.ForwardCurrentTime();
+ if (index == 1)
+ testPoint = t;
+ await _timelineService.CreateTextPost(timelineName, userId, content, null);
+ }
+
+ var posts = await _timelineService.GetPosts(timelineName, testPoint);
+ posts.Should().HaveCount(3)
+ .And.Subject.Select(p => (p.Content as TextTimelinePostContent).Text).Should().Equal(postContentList.Skip(1));
+ }
}
}
diff --git a/Timeline/Controllers/TimelineController.cs b/Timeline/Controllers/TimelineController.cs
index 36e9fe6d..b8cc608b 100644
--- a/Timeline/Controllers/TimelineController.cs
+++ b/Timeline/Controllers/TimelineController.cs
@@ -102,16 +102,20 @@ namespace Timeline.Controllers
}
[HttpGet("timelines/{name}/posts")]
- public async Task<ActionResult<List<TimelinePostInfo>>> PostListGet([FromRoute][GeneralTimelineName] string name)
+ public async Task<ActionResult<List<TimelinePostInfo>>> PostListGet([FromRoute][GeneralTimelineName] string name, [FromQuery] DateTime? modifiedSince)
{
if (!this.IsAdministrator() && !await _service.HasReadPermission(name, this.GetOptionalUserId()))
{
return StatusCode(StatusCodes.Status403Forbidden, ErrorResponse.Common.Forbid());
}
- var posts = await _service.GetPosts(name);
- var result = _mapper.Map<List<TimelinePostInfo>>(posts);
+ List<TimelinePost> posts;
+ if (modifiedSince == null)
+ posts = await _service.GetPosts(name);
+ else
+ posts = await _service.GetPosts(name, modifiedSince.Value);
+ var result = _mapper.Map<List<TimelinePostInfo>>(posts);
return result;
}
diff --git a/Timeline/Resources/Services/TimelineService.Designer.cs b/Timeline/Resources/Services/TimelineService.Designer.cs
index cfc381f9..e16c1337 100644
--- a/Timeline/Resources/Services/TimelineService.Designer.cs
+++ b/Timeline/Resources/Services/TimelineService.Designer.cs
@@ -106,6 +106,15 @@ namespace Timeline.Resources.Services {
}
/// <summary>
+ /// Looks up a localized string similar to The post has been deleted because content of entity is null..
+ /// </summary>
+ internal static string ExceptionPostDeleted {
+ get {
+ return ResourceManager.GetString("ExceptionPostDeleted", resourceCulture);
+ }
+ }
+
+ /// <summary>
/// Looks up a localized string similar to The timeline name is of bad format..
/// </summary>
internal static string ExceptionTimelineNameBadFormat {
diff --git a/Timeline/Resources/Services/TimelineService.resx b/Timeline/Resources/Services/TimelineService.resx
index c4f49b30..9314f51b 100644
--- a/Timeline/Resources/Services/TimelineService.resx
+++ b/Timeline/Resources/Services/TimelineService.resx
@@ -141,4 +141,7 @@
<data name="LogGetDataNoFormat" xml:space="preserve">
<value>Image format type of the post does not exist in column "extra_content". Normally this couldn't be possible because it should be saved when post was created. However, we now re-detect the format and save it.</value>
</data>
+ <data name="ExceptionPostDeleted" xml:space="preserve">
+ <value>The post has been deleted because content of entity is null.</value>
+ </data>
</root> \ No newline at end of file
diff --git a/Timeline/Services/TimelineService.cs b/Timeline/Services/TimelineService.cs
index 6c1e91c6..73f6c8ef 100644
--- a/Timeline/Services/TimelineService.cs
+++ b/Timeline/Services/TimelineService.cs
@@ -106,6 +106,20 @@ namespace Timeline.Services
Task<List<TimelinePost>> GetPosts(string timelineName);
/// <summary>
+ /// Get the posts that have been modified since a given time in the timeline.
+ /// </summary>
+ /// <param name="timelineName">The name of the timeline.</param>
+ /// <param name="modifiedSince">The time that posts have been modified since.</param>
+ /// <returns>A list of all posts.</returns>
+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="timelineName"/> is null.</exception>
+ /// <exception cref="ArgumentException">Throw when <paramref name="timelineName"/> is of bad format.</exception>
+ /// <exception cref="TimelineNotExistException">
+ /// Thrown when timeline with name <paramref name="timelineName"/> does not exist.
+ /// If it is a personal timeline, then inner exception is <see cref="UserNotExistException"/>.
+ /// </exception>
+ Task<List<TimelinePost>> GetPosts(string timelineName, DateTime modifiedSince);
+
+ /// <summary>
/// Get the etag of data of a post.
/// </summary>
/// <param name="timelineName">The name of the timeline of the post.</param>
@@ -383,6 +397,34 @@ namespace Timeline.Services
};
}
+ private async Task<TimelinePost> MapTimelinePostFromEntity(TimelinePostEntity entity, string timelineName)
+ {
+ if (entity.Content == null)
+ {
+ throw new ArgumentException(ExceptionPostDeleted, nameof(entity));
+ }
+
+ var author = await _userService.GetUserById(entity.AuthorId);
+
+ var type = entity.ContentType;
+
+ ITimelinePostContent content = type switch
+ {
+ TimelinePostContentTypes.Text => new TextTimelinePostContent(entity.Content),
+ TimelinePostContentTypes.Image => new ImageTimelinePostContent(entity.Content),
+ _ => throw new DatabaseCorruptedException(string.Format(CultureInfo.InvariantCulture, ExceptionDatabaseUnknownContentType, type))
+ };
+
+ return new TimelinePost(
+ id: entity.LocalId,
+ author: author,
+ content: content,
+ time: entity.Time,
+ lastUpdated: entity.LastUpdated,
+ timelineName: timelineName
+ );
+ }
+
private TimelineEntity CreateNewTimelineEntity(string? name, long ownerId)
{
var currentTime = _clock.GetCurrentTime();
@@ -488,30 +530,23 @@ namespace Timeline.Services
var posts = new List<TimelinePost>();
foreach (var entity in postEntities)
{
- if (entity.Content == null)
- {
- throw new Exception();
- }
-
- var author = await _userService.GetUserById(entity.AuthorId);
+ posts.Add(await MapTimelinePostFromEntity(entity, timelineName));
+ }
+ return posts;
+ }
- var type = entity.ContentType;
+ public async Task<List<TimelinePost>> GetPosts(string timelineName, DateTime modifiedSince)
+ {
+ if (timelineName == null)
+ throw new ArgumentNullException(nameof(timelineName));
- ITimelinePostContent content = type switch
- {
- TimelinePostContentTypes.Text => new TextTimelinePostContent(entity.Content),
- TimelinePostContentTypes.Image => new ImageTimelinePostContent(entity.Content),
- _ => throw new DatabaseCorruptedException(string.Format(CultureInfo.InvariantCulture, ExceptionDatabaseUnknownContentType, type))
- };
+ var timelineId = await FindTimelineId(timelineName);
+ var postEntities = await _database.TimelinePosts.OrderBy(p => p.Time).Where(p => p.TimelineId == timelineId && p.Content != null && p.LastUpdated >= modifiedSince).ToListAsync();
- posts.Add(new TimelinePost(
- id: entity.LocalId,
- author: author,
- content: content,
- time: entity.Time,
- lastUpdated: entity.LastUpdated,
- timelineName: timelineName
- ));
+ var posts = new List<TimelinePost>();
+ foreach (var entity in postEntities)
+ {
+ posts.Add(await MapTimelinePostFromEntity(entity, timelineName));
}
return posts;
}