From 9e84b1e9ad1f2a45cd3e09759c69989fdc588c3d Mon Sep 17 00:00:00 2001 From: crupest Date: Sun, 2 Feb 2020 00:31:33 +0800 Subject: ... --- Timeline/Services/TimelineService.cs | 133 ++++++++++++++++------------------- 1 file changed, 59 insertions(+), 74 deletions(-) (limited to 'Timeline/Services/TimelineService.cs') diff --git a/Timeline/Services/TimelineService.cs b/Timeline/Services/TimelineService.cs index 0ea68265..a16237ca 100644 --- a/Timeline/Services/TimelineService.cs +++ b/Timeline/Services/TimelineService.cs @@ -24,6 +24,37 @@ namespace Timeline.Services /// public interface IBaseTimelineService { + /// + /// Get the timeline info. + /// + /// Username or the timeline name. See remarks of . + /// The timeline info. + /// Thrown when is null. + /// Thrown when is illegal. It is not a valid timeline name (for normal timeline service) or a valid username (for personal timeline service). + /// + /// 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 . + /// + Task GetTimeline(string name); + + /// + /// Set the properties of a timeline. + /// + /// Username or the timeline name. See remarks of . + /// The new properties. Null member means not to change. + /// The timeline info. + /// Thrown when is null. + /// Thrown when is illegal. It is not a valid timeline name (for normal timeline service) or a valid username (for personal timeline service). + /// + /// 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 . + /// + Task ChangeProperty(string name, TimelinePatchRequest newProperties); + /// /// Get all the posts in the timeline. /// @@ -177,20 +208,6 @@ namespace Timeline.Services /// public interface ITimelineService : IBaseTimelineService { - /// - /// Get the timeline info. - /// - /// The name of the timeline. - /// The timeline info. - /// Thrown when is null. - /// - /// Thrown when timeline name is invalid. Currently it means it is an empty string. - /// - /// - /// Thrown when timeline with the name does not exist. - /// - Task GetTimeline(string name); - /// /// Create a timeline. /// @@ -205,37 +222,6 @@ namespace Timeline.Services public interface IPersonalTimelineService : IBaseTimelineService { - /// - /// Get the timeline info. - /// - /// The username of the owner of the personal timeline. - /// The timeline info. - /// - /// Thrown when is null. - /// - /// - /// Thrown when is of bad format. - /// - /// - /// Thrown when the user does not exist. Inner exception MUST be . - /// - Task GetTimeline(string username); - - /// - /// Set the properties of a timeline. - /// - /// Username or the timeline name. See remarks of . - /// The new properties. Null member means not to change. - /// - /// Thrown when is null. - /// - /// - /// Thrown when is of bad format. - /// - /// - /// Thrown when the user does not exist. Inner exception MUST be . - /// - Task ChangeProperty(string name, TimelinePatchRequest newProperties); } @@ -283,6 +269,34 @@ namespace Timeline.Services /// protected abstract Task FindTimelineId(string name); + public async Task 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(await UserService.GetUserById(timelineEntity.OwnerId)); + + var members = new List(); + foreach (var memberEntity in timelineMemberEntities) + { + members.Add(Mapper.Map(await UserService.GetUserById(memberEntity.UserId))); + } + + return new TimelineInfo + { + Description = timelineEntity.Description ?? "", + Owner = owner, + Visibility = timelineEntity.Visibility, + Members = members + }; + } + public async Task> GetPosts(string name) { if (name == null) @@ -569,34 +583,5 @@ namespace Timeline.Services return newTimelineEntity.Id; } } - - public async Task 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(await UserService.GetUserById(timelineEntity.OwnerId)); - - var members = new List(); - foreach (var memberEntity in timelineMemberEntities) - { - members.Add(Mapper.Map(await UserService.GetUserById(memberEntity.UserId))); - } - - return new BaseTimelineInfo - { - Description = timelineEntity.Description ?? "", - Owner = owner, - Visibility = timelineEntity.Visibility, - Members = members - }; - } - } } -- cgit v1.2.3 From cb18564275735da0614be39371e2e3e7f5467e88 Mon Sep 17 00:00:00 2001 From: crupest Date: Sun, 2 Feb 2020 14:35:30 +0800 Subject: Add timeline service. --- Timeline/Entities/TimelineEntity.cs | 4 +- Timeline/Models/Validation/NameValidator.cs | 33 ++++++ .../Models/Validation/TimelineNameValidator.cs | 19 +++ Timeline/Models/Validation/UsernameValidator.cs | 28 +---- .../Models/Validation/NameValidator.Designer.cs | 90 ++++++++++++++ .../Resources/Models/Validation/NameValidator.resx | 129 +++++++++++++++++++++ .../Validation/UsernameValidator.Designer.cs | 90 -------------- .../Models/Validation/UsernameValidator.resx | 129 --------------------- .../Resources/Services/TimelineService.Designer.cs | 18 +++ Timeline/Resources/Services/TimelineService.resx | 6 + Timeline/Services/TimelineService.cs | 96 +++++++++++++-- Timeline/Timeline.csproj | 8 +- 12 files changed, 388 insertions(+), 262 deletions(-) create mode 100644 Timeline/Models/Validation/NameValidator.cs create mode 100644 Timeline/Models/Validation/TimelineNameValidator.cs create mode 100644 Timeline/Resources/Models/Validation/NameValidator.Designer.cs create mode 100644 Timeline/Resources/Models/Validation/NameValidator.resx delete mode 100644 Timeline/Resources/Models/Validation/UsernameValidator.Designer.cs delete mode 100644 Timeline/Resources/Models/Validation/UsernameValidator.resx (limited to 'Timeline/Services/TimelineService.cs') 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 Posts { get; set; } = default!; } +#pragma warning restore CA2227 // Collection properties should be read only } 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 + { + 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 + 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/Models/Validation/NameValidator.Designer.cs b/Timeline/Resources/Models/Validation/NameValidator.Designer.cs new file mode 100644 index 00000000..5b869226 --- /dev/null +++ b/Timeline/Resources/Models/Validation/NameValidator.Designer.cs @@ -0,0 +1,90 @@ +//------------------------------------------------------------------------------ +// +// 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. +// +//------------------------------------------------------------------------------ + +namespace Timeline.Resources.Models.Validation { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // 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 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 NameValidator() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [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.Models.Validation.NameValidator", typeof(NameValidator).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to An empty string is not allowed.. + /// + internal static string MessageEmptyString { + get { + return ResourceManager.GetString("MessageEmptyString", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Invalid character, only alphabet, digit, underscore and hyphen are allowed.. + /// + internal static string MessageInvalidChar { + get { + return ResourceManager.GetString("MessageInvalidChar", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Too long, more than 26 characters is not premitted.. + /// + internal static string MessageTooLong { + get { + return ResourceManager.GetString("MessageTooLong", resourceCulture); + } + } + } +} diff --git a/Timeline/Resources/Models/Validation/NameValidator.resx b/Timeline/Resources/Models/Validation/NameValidator.resx new file mode 100644 index 00000000..08a814d0 --- /dev/null +++ b/Timeline/Resources/Models/Validation/NameValidator.resx @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + An empty string is not allowed. + + + Invalid character, only alphabet, digit, underscore and hyphen are allowed. + + + Too long, more than 26 characters is not premitted. + + \ No newline at end of file diff --git a/Timeline/Resources/Models/Validation/UsernameValidator.Designer.cs b/Timeline/Resources/Models/Validation/UsernameValidator.Designer.cs deleted file mode 100644 index ac925504..00000000 --- a/Timeline/Resources/Models/Validation/UsernameValidator.Designer.cs +++ /dev/null @@ -1,90 +0,0 @@ -//------------------------------------------------------------------------------ -// -// 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. -// -//------------------------------------------------------------------------------ - -namespace Timeline.Resources.Models.Validation { - using System; - - - /// - /// A strongly-typed resource class, for looking up localized strings, etc. - /// - // 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 UsernameValidator { - - 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() { - } - - /// - /// Returns the cached ResourceManager instance used by this class. - /// - [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.Models.Validation.UsernameValidator", typeof(UsernameValidator).Assembly); - resourceMan = temp; - } - return resourceMan; - } - } - - /// - /// Overrides the current thread's CurrentUICulture property for all - /// resource lookups using this strongly typed resource class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Globalization.CultureInfo Culture { - get { - return resourceCulture; - } - set { - resourceCulture = value; - } - } - - /// - /// Looks up a localized string similar to An empty string is not allowed.. - /// - internal static string MessageEmptyString { - get { - return ResourceManager.GetString("MessageEmptyString", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Invalid character, only alphabet, digit, underscore and hyphen are allowed.. - /// - internal static string MessageInvalidChar { - get { - return ResourceManager.GetString("MessageInvalidChar", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Too long, more than 26 characters is not premitted.. - /// - internal static string MessageTooLong { - get { - return ResourceManager.GetString("MessageTooLong", resourceCulture); - } - } - } -} diff --git a/Timeline/Resources/Models/Validation/UsernameValidator.resx b/Timeline/Resources/Models/Validation/UsernameValidator.resx deleted file mode 100644 index 08a814d0..00000000 --- a/Timeline/Resources/Models/Validation/UsernameValidator.resx +++ /dev/null @@ -1,129 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - An empty string is not allowed. - - - Invalid character, only alphabet, digit, underscore and hyphen are allowed. - - - Too long, more than 26 characters is not premitted. - - \ No newline at end of file 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); } } + + /// + /// Looks up a localized string similar to The timeline name is of bad format because {0}.. + /// + internal static string ExceptionTimelineNameBadFormat { + get { + return ResourceManager.GetString("ExceptionTimelineNameBadFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The timeline with given name already exists.. + /// + 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 @@ The owner username of personal timeline is of bad format. + + The timeline name is of bad format because {0}. + + + The timeline with given name already exists. + \ No newline at end of file diff --git a/Timeline/Services/TimelineService.cs b/Timeline/Services/TimelineService.cs index a16237ca..b031297e 100644 --- a/Timeline/Services/TimelineService.cs +++ b/Timeline/Services/TimelineService.cs @@ -213,11 +213,12 @@ namespace Timeline.Services /// /// The name of the timeline. /// The id of owner of the timeline. - /// Thrown when or is null. - /// Thrown when timeline name is invalid. Currently it means it is an empty string. + /// The info of the new timeline. + /// Thrown when is null. + /// Thrown when timeline name is invalid. /// Thrown when the timeline already exists. /// Thrown when the owner user does not exist. - Task CreateTimeline(string name, long owner); + Task CreateTimeline(string name, long owner); } public interface IPersonalTimelineService : IBaseTimelineService @@ -245,6 +246,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() + }; + } + /// /// Find the timeline id by the name. /// For details, see remarks. @@ -537,6 +549,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 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 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(user), + Visibility = newEntity.Visibility, + Members = new List() + }; + } + } + public class PersonalTimelineService : BaseTimelineService, IPersonalTimelineService { public PersonalTimelineService(ILoggerFactory loggerFactory, DatabaseContext database, IUserService userService, IMapper mapper, IClock clock) @@ -547,6 +625,9 @@ namespace Timeline.Services protected override async Task FindTimelineId(string name) { + if (name == null) + throw new ArgumentNullException(nameof(name)); + long userId; try { @@ -569,14 +650,7 @@ 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(); 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 @@ True NicknameValidator.resx - + True True - UsernameValidator.resx + NameValidator.resx True @@ -163,9 +163,9 @@ ResXFileCodeGenerator NicknameValidator.Designer.cs - + ResXFileCodeGenerator - UsernameValidator.Designer.cs + NameValidator.Designer.cs ResXFileCodeGenerator -- cgit v1.2.3 From 12f85448cde94d70d9030b757b09caa5e2f53061 Mon Sep 17 00:00:00 2001 From: crupest Date: Sun, 2 Feb 2020 22:37:47 +0800 Subject: ... --- Timeline/Controllers/PersonalTimelineController.cs | 10 +- Timeline/Controllers/TimelineController.cs | 131 +++++++++++++++++++++ Timeline/Models/Http/TimelineCommon.cs | 17 ++- Timeline/Services/TimelineService.cs | 31 +++++ 4 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 Timeline/Controllers/TimelineController.cs (limited to 'Timeline/Services/TimelineService.cs') diff --git a/Timeline/Controllers/PersonalTimelineController.cs b/Timeline/Controllers/PersonalTimelineController.cs index 842da015..8cf098bf 100644 --- a/Timeline/Controllers/PersonalTimelineController.cs +++ b/Timeline/Controllers/PersonalTimelineController.cs @@ -28,7 +28,7 @@ namespace Timeline.Controllers [HttpGet("users/{username}/timeline")] public async Task> TimelineGet([FromRoute][Username] string username) { - return (await _service.GetTimeline(username)).FillLinks(Url); + return (await _service.GetTimeline(username)).FillLinksForPersonalTimeline(Url); } [HttpGet("users/{username}/timeline/posts")] @@ -79,12 +79,12 @@ namespace Timeline.Controllers [Authorize] public async Task> 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)).FillLinks(Url); + var timeline = (await _service.GetTimeline(username)).FillLinksForPersonalTimeline(Url); return Ok(timeline); } @@ -92,7 +92,7 @@ namespace Timeline.Controllers [Authorize] public async Task 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()); } @@ -112,7 +112,7 @@ namespace Timeline.Controllers [Authorize] public async Task 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..be271de7 --- /dev/null +++ b/Timeline/Controllers/TimelineController.cs @@ -0,0 +1,131 @@ +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.Models.Http; +using Timeline.Models.Validation; +using Timeline.Services; + +namespace Timeline.Controllers +{ + [ApiController] + public class TimelineController : Controller + { + private readonly ILogger _logger; + + private readonly ITimelineService _service; + + public TimelineController(ILogger logger, ITimelineService service) + { + _logger = logger; + _service = service; + } + + [HttpGet("timelines/{name}")] + public async Task> TimelineGet([FromRoute][TimelineName] string name) + { + return (await _service.GetTimeline(name)).FillLinksForNormalTimeline(Url); + } + + [HttpGet("timelines/{name}/posts")] + public async Task>> 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> 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 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> 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 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 { member }, null); + return Ok(); + } + catch (UserNotExistException) + { + return BadRequest(ErrorResponse.TimelineController.MemberPut_NotExist()); + } + } + + [HttpDelete("timelines/{name}/members/{member}")] + [Authorize] + public async Task 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 { member }); + return Ok(CommonDeleteResponse.Delete()); + } + catch (UserNotExistException) + { + return Ok(CommonDeleteResponse.NotExist()); + } + } + + // TODO: Create API . + } +} diff --git a/Timeline/Models/Http/TimelineCommon.cs b/Timeline/Models/Http/TimelineCommon.cs index 0b2a714c..1cb47dac 100644 --- a/Timeline/Models/Http/TimelineCommon.cs +++ b/Timeline/Models/Http/TimelineCommon.cs @@ -52,7 +52,7 @@ namespace Timeline.Models.Http public static class TimelineInfoExtensions { - public static TimelineInfo FillLinks(this TimelineInfo info, IUrlHelper urlHelper) + public static TimelineInfo FillLinksForPersonalTimeline(this TimelineInfo info, IUrlHelper urlHelper) { if (info == null) throw new ArgumentNullException(nameof(info)); @@ -66,5 +66,20 @@ namespace Timeline.Models.Http 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/Services/TimelineService.cs b/Timeline/Services/TimelineService.cs index b031297e..991669ad 100644 --- a/Timeline/Services/TimelineService.cs +++ b/Timeline/Services/TimelineService.cs @@ -138,6 +138,26 @@ namespace Timeline.Services /// Task ChangeMember(string name, IList? add, IList? remove); + /// + /// Check whether a user can manage(change timeline info, member, ...) a timeline. + /// + /// + /// + /// True if the user can manage the timeline, otherwise false. + /// Thrown when is null. + /// Thrown when is illegal. It is not a valid timeline name (for normal timeline service) or a valid username (for personal timeline service). + /// + /// 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 . + /// + /// + /// This method does not check whether visitor is administrator. + /// Return false if user with user id does not exist. + /// + Task HasManagePermission(string name, long userId); + /// /// Verify whether a visitor has the permission to read a timeline. /// @@ -490,6 +510,17 @@ namespace Timeline.Services await Database.SaveChangesAsync(); } + public async Task 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 HasReadPermission(string name, long? visitorId) { if (name == null) -- cgit v1.2.3 From be8cbe2c4ddf2076cc02bcb2feb1a70d30a4bda0 Mon Sep 17 00:00:00 2001 From: crupest Date: Mon, 3 Feb 2020 18:18:45 +0800 Subject: Finish normal timeline development. --- Timeline.Tests/IntegratedTests/TimelineTest.cs | 589 +++++++++++++++++++++++++ Timeline/Controllers/TimelineController.cs | 9 +- Timeline/Services/TimelineService.cs | 1 + Timeline/Startup.cs | 1 + 4 files changed, 597 insertions(+), 3 deletions(-) create mode 100644 Timeline.Tests/IntegratedTests/TimelineTest.cs (limited to 'Timeline/Services/TimelineService.cs') 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 factory) + : base(factory, 3) + { + + } + + private List _testTimelines; + + private async Task CreateTestTimelines() + { + _testTimelines = new List(); + 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().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().Which; + } + + { + var res = await client.GetAsync("timelines/aaa"); + res.Should().HaveStatusCode(200) + .And.HaveJsonBody() + .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() + .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().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().Which.Description.Should().Be(mockDescription); + await AssertDescription(mockDescription); + } + { + var res = await client.PatchAsJsonAsync("timelines/t1", + new TimelinePatchRequest { Description = "" }); + res.Should().HaveStatusCode(200) + .And.HaveJsonBody().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 members) + { + var res = await client.GetAsync(getUrl); + res.Should().HaveStatusCode(200) + .And.HaveJsonBody() + .Which.Members.Should().NotBeNull().And.BeEquivalentTo(members); + } + + async Task AssertEmptyMembers() + { + var res = await client.GetAsync(getUrl); + res.Should().HaveStatusCode(200) + .And.HaveJsonBody() + .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 { 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 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() + .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() + .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() + .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() + .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() + .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() + .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() + .Which.Should().NotBeNull().And.BeEquivalentTo(createRes2); + } + } + } + + [Fact] + public async Task GetPost_Should_Ordered() + { + await CreateTestTimelines(); + + using var client = await CreateClientAsUser(); + + async Task CreatePost(DateTime time) + { + var res = await client.PostAsJsonAsync("timelines/t1/posts", + new TimelinePostCreateRequest { Content = "aaa", Time = time }); + return res.Should().HaveStatusCode(200) + .And.HaveJsonBody() + .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() + .Which.Select(p => p.Id).Should().Equal(id1, id2, id0); + } + } + } +} diff --git a/Timeline/Controllers/TimelineController.cs b/Timeline/Controllers/TimelineController.cs index d46189b8..a514ccd9 100644 --- a/Timeline/Controllers/TimelineController.cs +++ b/Timeline/Controllers/TimelineController.cs @@ -4,6 +4,7 @@ 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; @@ -11,6 +12,7 @@ using Timeline.Services; namespace Timeline.Controllers { [ApiController] + [CatchTimelineNotExistException] public class TimelineController : Controller { private readonly ILogger _logger; @@ -26,7 +28,8 @@ namespace Timeline.Controllers [HttpGet("timelines/{name}")] public async Task> TimelineGet([FromRoute][TimelineName] string name) { - return (await _service.GetTimeline(name)).FillLinksForNormalTimeline(Url); + var result = (await _service.GetTimeline(name)).FillLinksForNormalTimeline(Url); + return Ok(result); } [HttpGet("timelines/{name}/posts")] @@ -128,13 +131,13 @@ namespace Timeline.Controllers [HttpPost("timelines")] [Authorize] - public async Task> TimelineCreate([FromRoute] TimelineCreateRequest body) + public async Task> TimelineCreate([FromBody] TimelineCreateRequest body) { var userId = this.GetUserId(); try { - var timelineInfo = await _service.CreateTimeline(body.Name, userId); + var timelineInfo = (await _service.CreateTimeline(body.Name, userId)).FillLinksForNormalTimeline(Url); return Ok(timelineInfo); } catch (ConflictException) diff --git a/Timeline/Services/TimelineService.cs b/Timeline/Services/TimelineService.cs index 991669ad..6f9d437e 100644 --- a/Timeline/Services/TimelineService.cs +++ b/Timeline/Services/TimelineService.cs @@ -322,6 +322,7 @@ namespace Timeline.Services return new TimelineInfo { + Name = timelineEntity.Name, Description = timelineEntity.Description ?? "", Owner = owner, Visibility = timelineEntity.Visibility, 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(); services.AddUserAvatarService(); + services.AddScoped(); services.AddScoped(); services.TryAddSingleton(); -- cgit v1.2.3