diff --git a/Refresh.Database/GameDatabaseContext.Comments.cs b/Refresh.Database/GameDatabaseContext.Comments.cs index 139cca76b..c2e3f402c 100644 --- a/Refresh.Database/GameDatabaseContext.Comments.cs +++ b/Refresh.Database/GameDatabaseContext.Comments.cs @@ -38,13 +38,10 @@ public GameProfileComment PostCommentToProfile(GameUser profile, GameUser author return comment; } - public IEnumerable GetProfileComments(GameUser profile, int count, int skip) => - this.GameProfileCommentsIncluded + public DatabaseList GetProfileComments(GameUser profile, int count, int skip) => + new(this.GameProfileCommentsIncluded .Where(c => c.Profile == profile) - .OrderByDescending(c => c.Timestamp) - .AsEnumerableIfRealm() - .Skip(skip) - .Take(count); + .OrderByDescending(c => c.Timestamp), skip, count); [Pure] public int GetTotalCommentsForProfile(GameUser profile) => this.GameProfileComments.Count(c => c.Profile == profile); @@ -80,13 +77,10 @@ public GameLevelComment PostCommentToLevel(GameLevel level, GameUser author, str return comment; } - public IEnumerable GetLevelComments(GameLevel level, int count, int skip) => - this.GameLevelCommentsIncluded + public DatabaseList GetLevelComments(GameLevel level, int count, int skip) => + new(this.GameLevelCommentsIncluded .Where(c => c.Level == level) - .OrderByDescending(c => c.Timestamp) - .AsEnumerableIfRealm() - .Skip(skip) - .Take(count); + .OrderByDescending(c => c.Timestamp), skip, count); [Pure] public int GetTotalCommentsForLevel(GameLevel level) => this.GameLevelComments.Count(c => c.Level == level); diff --git a/Refresh.Database/GameDatabaseContext.Relations.cs b/Refresh.Database/GameDatabaseContext.Relations.cs index 4da8b5d59..1f81adc87 100644 --- a/Refresh.Database/GameDatabaseContext.Relations.cs +++ b/Refresh.Database/GameDatabaseContext.Relations.cs @@ -672,17 +672,16 @@ public int GetTotalRatingsForProfileComment(GameProfileComment comment, RatingTy public int GetTotalRatingsForLevelComment(GameLevelComment comment, RatingType type) => this.LevelCommentRelations.Count(r => r.Comment == comment && r.RatingType == type); - private bool RateComment(GameUser user, TComment comment, RatingType ratingType, DbSet list) + private void RateComment(GameUser user, TComment comment, RatingType ratingType, DbSet list) where TComment : class, IGameComment where TCommentRelation : class, ICommentRelation, new() { - if (ratingType == RatingType.Neutral) - return false; - TCommentRelation? relation = list.FirstOrDefault(r => r.Comment == comment && r.User == user); if (relation == null) { + if (ratingType == RatingType.Neutral) return; + relation = new TCommentRelation { User = user, @@ -698,20 +697,28 @@ private bool RateComment(GameUser user, TComment com } else { - this.Write(() => + if (ratingType == RatingType.Neutral) { - relation.Timestamp = this._time.Now; - relation.RatingType = ratingType; - }); + this.Write(() => + { + list.Remove(relation); + }); + } + else + { + this.Write(() => + { + relation.Timestamp = this._time.Now; + relation.RatingType = ratingType; + }); + } } - - return true; } - public bool RateProfileComment(GameUser user, GameProfileComment comment, RatingType ratingType) + public void RateProfileComment(GameUser user, GameProfileComment comment, RatingType ratingType) => this.RateComment(user, comment, ratingType, this.ProfileCommentRelations); - public bool RateLevelComment(GameUser user, GameLevelComment comment, RatingType ratingType) + public void RateLevelComment(GameUser user, GameLevelComment comment, RatingType ratingType) => this.RateComment(user, comment, ratingType, this.LevelCommentRelations); #endregion diff --git a/Refresh.Interfaces.APIv3/Endpoints/ApiTypes/Errors/ApiValidationError.cs b/Refresh.Interfaces.APIv3/Endpoints/ApiTypes/Errors/ApiValidationError.cs index dc1c4c0a1..cbe4fbaf6 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/ApiTypes/Errors/ApiValidationError.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/ApiTypes/Errors/ApiValidationError.cs @@ -10,12 +10,18 @@ public class ApiValidationError : ApiError public const string NumberParseErrorWhen = "The number could not be parsed by the server"; public static readonly ApiValidationError NumberParseError = new(NumberParseErrorWhen); + + public const string RatingParseErrorWhen = "The given rating could not be parsed by the server"; + public static readonly ApiValidationError RatingParseError = new(RatingParseErrorWhen); public const string IpAddressParseErrorWhen = "The IP address could not be parsed by the server"; public static readonly ApiValidationError IpAddressParseError = new(IpAddressParseErrorWhen); public const string NoPhotoDeletionPermissionErrorWhen = "You do not have permission to delete someone else's photo"; public static readonly ApiValidationError NoPhotoDeletionPermissionError = new(NoPhotoDeletionPermissionErrorWhen); + + public const string NoCommentDeletionPermissionErrorWhen = "You do not have permission to delete this comment"; + public static readonly ApiValidationError NoCommentDeletionPermissionError = new(NoCommentDeletionPermissionErrorWhen); public const string HashInvalidErrorWhen = "The hash is invalid (should be SHA1 hash)"; public static readonly ApiValidationError HashInvalidError = new(HashInvalidErrorWhen); diff --git a/Refresh.Interfaces.APIv3/Endpoints/CommentApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/CommentApiEndpoints.cs new file mode 100644 index 000000000..462cf9127 --- /dev/null +++ b/Refresh.Interfaces.APIv3/Endpoints/CommentApiEndpoints.cs @@ -0,0 +1,170 @@ +using AttribDoc.Attributes; +using Bunkum.Core; +using Bunkum.Core.Endpoints; +using Bunkum.Protocols.Http; +using Refresh.Core.Types.Data; +using Refresh.Database; +using Refresh.Database.Models.Comments; +using Refresh.Database.Models.Levels; +using Refresh.Database.Models.Users; +using Refresh.Interfaces.APIv3.Documentation.Attributes; +using Refresh.Interfaces.APIv3.Endpoints.ApiTypes; +using Refresh.Interfaces.APIv3.Endpoints.ApiTypes.Errors; +using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Request; +using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Comments; +using Refresh.Interfaces.APIv3.Extensions; + +namespace Refresh.Interfaces.APIv3.Endpoints; + +public class CommentApiEndpoints : EndpointGroup +{ + #region Profile + [ApiV3Endpoint("users/uuid/{uuid}/comments"), Authentication(false)] + [DocSummary("Gets comments posted under the specified user's profile.")] + [DocError(typeof(ApiNotFoundError), ApiNotFoundError.UserMissingErrorWhen)] + [DocUsesPageData] + public ApiListResponse GetCommentsOnProfile(RequestContext context, DataContext dataContext, string uuid) + { + GameUser? profile = dataContext.Database.GetUserByUuid(uuid); + if (profile == null) return ApiNotFoundError.UserMissingError; + + (int skip, int count) = context.GetPageData(); + + DatabaseList comments = dataContext.Database.GetProfileComments(profile, count, skip); + return DatabaseListExtensions.FromOldList(comments, dataContext); + } + + [ApiV3Endpoint("users/uuid/{uuid}/comments", HttpMethods.Post)] + [DocSummary("Posts the given comment under the specified user's profile.")] + [DocError(typeof(ApiNotFoundError), ApiNotFoundError.UserMissingErrorWhen)] + public ApiResponse PostCommentOnProfile(RequestContext context, + DataContext dataContext, string uuid, GameUser user, ApiCommentPostRequest body) + { + GameUser? profile = dataContext.Database.GetUserByUuid(uuid); + if (profile == null) return ApiNotFoundError.UserMissingError; + + GameProfileComment comment = dataContext.Database.PostCommentToProfile(profile, user, body.Content); + return ApiProfileCommentResponse.FromOld(comment, dataContext); + } + + [ApiV3Endpoint("profileComments/id/{id}"), Authentication(false)] + [DocSummary("Gets the profile comment specified by its ID.")] + [DocError(typeof(ApiNotFoundError), ApiNotFoundError.CommentMissingErrorWhen)] + public ApiResponse GetProfileComment(RequestContext context, DataContext dataContext, int id) + { + GameProfileComment? comment = dataContext.Database.GetProfileCommentById(id); + if (comment == null) return ApiNotFoundError.CommentMissingError; + + return ApiProfileCommentResponse.FromOld(comment, dataContext); + } + + [ApiV3Endpoint("profileComments/id/{id}", HttpMethods.Delete)] + [DocSummary("Deletes the profile comment specified by its ID. Fails if the user is not the comment poster or the profile owner.")] + [DocError(typeof(ApiNotFoundError), ApiNotFoundError.CommentMissingErrorWhen)] + [DocError(typeof(ApiValidationError), ApiValidationError.NoCommentDeletionPermissionErrorWhen)] + public ApiOkResponse DeleteProfileComment(RequestContext context, DataContext dataContext, GameUser user, int id) + { + GameProfileComment? comment = dataContext.Database.GetProfileCommentById(id); + if (comment == null) return ApiNotFoundError.CommentMissingError; + + if (user != comment.Author && user != comment.Profile) return ApiValidationError.NoCommentDeletionPermissionError; + + dataContext.Database.DeleteProfileComment(comment); + return new ApiOkResponse(); + } + + [ApiV3Endpoint("profileComments/id/{id}/rate/{rawRating}", HttpMethods.Post)] + [DocSummary("Rates the profile comment specified by its ID.")] + [DocError(typeof(ApiNotFoundError), ApiNotFoundError.CommentMissingErrorWhen)] + [DocError(typeof(ApiValidationError), ApiValidationError.RatingParseErrorWhen)] + public ApiOkResponse RateProfileComment(RequestContext context, DataContext dataContext, GameUser user, int id, + [DocSummary("The user's new rating for the comment. -1 = dislike, 0 = neutral, 1 = like.")] string rawRating) + { + GameProfileComment? comment = dataContext.Database.GetProfileCommentById(id); + if (comment == null) return ApiNotFoundError.CommentMissingError; + + // rawRating is string and not sbyte or integer because passing any out of range value will make Bunkum + // set rawRating to 0 instead, which we would here wrongly take as a neutral rating instead of an invalid value. + if (!sbyte.TryParse(rawRating, out sbyte rating) || !Enum.IsDefined(typeof(RatingType), rating)) + return ApiValidationError.RatingParseError; + + dataContext.Database.RateProfileComment(user, comment, (RatingType)rating); + return new ApiOkResponse(); + } + #endregion + + #region Level + [ApiV3Endpoint("levels/id/{id}/comments"), Authentication(false)] + [DocSummary("Gets comments posted under the specified level.")] + [DocError(typeof(ApiNotFoundError), ApiNotFoundError.LevelMissingErrorWhen)] + [DocUsesPageData] + public ApiListResponse GetCommentsOnLevel(RequestContext context, DataContext dataContext, int id) + { + GameLevel? level = dataContext.Database.GetLevelById(id); + if (level == null) return ApiNotFoundError.LevelMissingError; + + (int skip, int count) = context.GetPageData(); + + DatabaseList comments = dataContext.Database.GetLevelComments(level, count, skip); + return DatabaseListExtensions.FromOldList(comments, dataContext); + } + + [ApiV3Endpoint("levels/id/{id}/comments", HttpMethods.Post)] + [DocSummary("Posts the given comment under the specified level.")] + [DocError(typeof(ApiNotFoundError), ApiNotFoundError.LevelMissingErrorWhen)] + public ApiResponse PostCommentOnLevel(RequestContext context, + DataContext dataContext, int id, GameUser user, ApiCommentPostRequest body) + { + GameLevel? level = dataContext.Database.GetLevelById(id); + if (level == null) return ApiNotFoundError.LevelMissingError; + + GameLevelComment comment = dataContext.Database.PostCommentToLevel(level, user, body.Content); + return ApiLevelCommentResponse.FromOld(comment, dataContext); + } + + [ApiV3Endpoint("levelComments/id/{id}"), Authentication(false)] + [DocSummary("Gets the level comment specified by its ID.")] + [DocError(typeof(ApiNotFoundError), ApiNotFoundError.CommentMissingErrorWhen)] + public ApiResponse GetLevelComment(RequestContext context, DataContext dataContext, int id) + { + GameLevelComment? comment = dataContext.Database.GetLevelCommentById(id); + if (comment == null) return ApiNotFoundError.CommentMissingError; + + return ApiLevelCommentResponse.FromOld(comment, dataContext); + } + + [ApiV3Endpoint("levelComments/id/{id}", HttpMethods.Delete)] + [DocSummary("Deletes the level comment specified by its ID. Fails if the user is not the comment poster or the level publisher.")] + [DocError(typeof(ApiNotFoundError), ApiNotFoundError.CommentMissingErrorWhen)] + [DocError(typeof(ApiValidationError), ApiValidationError.NoCommentDeletionPermissionErrorWhen)] + public ApiOkResponse DeleteLevelComment(RequestContext context, DataContext dataContext, GameUser user, int id) + { + GameLevelComment? comment = dataContext.Database.GetLevelCommentById(id); + if (comment == null) return ApiNotFoundError.CommentMissingError; + + if (user != comment.Author && user != comment.Level.Publisher) return ApiValidationError.NoCommentDeletionPermissionError; + + dataContext.Database.DeleteLevelComment(comment); + return new ApiOkResponse(); + } + + [ApiV3Endpoint("levelComments/id/{id}/rate/{rawRating}", HttpMethods.Post)] + [DocSummary("Rates the level comment specified by its ID.")] + [DocError(typeof(ApiNotFoundError), ApiNotFoundError.CommentMissingErrorWhen)] + [DocError(typeof(ApiValidationError), ApiValidationError.RatingParseErrorWhen)] + public ApiOkResponse RateLevelComment(RequestContext context, DataContext dataContext, GameUser user, int id, + [DocSummary("The user's new rating for the comment. -1 = dislike, 0 = neutral, 1 = like.")] string rawRating) + { + GameLevelComment? comment = dataContext.Database.GetLevelCommentById(id); + if (comment == null) return ApiNotFoundError.CommentMissingError; + + // rawRating is string and not sbyte or integer because passing any out of range value will make Bunkum + // set rawRating to 0 instead, which we would here wrongly take as a neutral rating instead of an invalid value. + if (!sbyte.TryParse(rawRating, out sbyte rating) || !Enum.IsDefined(typeof(RatingType), rating)) + return ApiValidationError.RatingParseError; + + dataContext.Database.RateLevelComment(user, comment, (RatingType)rating); + return new ApiOkResponse(); + } + #endregion +} \ No newline at end of file diff --git a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Request/ApiCommentPostRequest.cs b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Request/ApiCommentPostRequest.cs new file mode 100644 index 000000000..2747c5fda --- /dev/null +++ b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Request/ApiCommentPostRequest.cs @@ -0,0 +1,7 @@ +namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Request; + +[JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))] +public class ApiCommentPostRequest +{ + public required string Content { get; set; } +} \ No newline at end of file diff --git a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Comments/ApiLevelCommentResponse.cs b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Comments/ApiLevelCommentResponse.cs new file mode 100644 index 000000000..4d3cc234e --- /dev/null +++ b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Comments/ApiLevelCommentResponse.cs @@ -0,0 +1,41 @@ +using Refresh.Core.Types.Data; +using Refresh.Database.Models.Comments; +using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Data; +using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Levels; +using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Users; + +namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Comments; + +[JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))] +public class ApiLevelCommentResponse : IApiResponse, IDataConvertableFrom +{ + public required int CommentId { get; set; } + public required string Content { get; set; } + public required ApiMinimalUserResponse Publisher { get; set; } + public required ApiMinimalLevelResponse Level { get; set; } + public required ApiRatingResponse Rating { get; set; } + public required DateTimeOffset Timestamp { get; set; } + + public static ApiLevelCommentResponse? FromOld(GameLevelComment? old, DataContext dataContext) + { + if (old == null) return null; + + return new ApiLevelCommentResponse + { + CommentId = old.SequentialId, + Content = old.Content, + Publisher = ApiMinimalUserResponse.FromOld(old.Author, dataContext)!, + Level = ApiMinimalLevelResponse.FromOld(old.Level, dataContext)!, + Rating = ApiRatingResponse.FromRating + ( + dataContext.Database.GetTotalRatingsForLevelComment(old, RatingType.Yay), + dataContext.Database.GetTotalRatingsForLevelComment(old, RatingType.Boo), + dataContext.User != null ? (int?)dataContext.Database.GetLevelCommentRatingByUser(old, dataContext.User) : 0 + ), + Timestamp = old.Timestamp, + }; + } + + public static IEnumerable FromOldList(IEnumerable oldList, DataContext dataContext) + => oldList.Select(old => FromOld(old, dataContext)).ToList()!; +} \ No newline at end of file diff --git a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Comments/ApiProfileCommentResponse.cs b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Comments/ApiProfileCommentResponse.cs new file mode 100644 index 000000000..d46ec16b4 --- /dev/null +++ b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Comments/ApiProfileCommentResponse.cs @@ -0,0 +1,40 @@ +using Refresh.Core.Types.Data; +using Refresh.Database.Models.Comments; +using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Data; +using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Users; + +namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Comments; + +[JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))] +public class ApiProfileCommentResponse : IApiResponse, IDataConvertableFrom +{ + public required int CommentId { get; set; } + public required string Content { get; set; } + public required ApiMinimalUserResponse Publisher { get; set; } + public required ApiMinimalUserResponse Profile { get; set; } + public required ApiRatingResponse Rating { get; set; } + public required DateTimeOffset Timestamp { get; set; } + + public static ApiProfileCommentResponse? FromOld(GameProfileComment? old, DataContext dataContext) + { + if (old == null) return null; + + return new ApiProfileCommentResponse + { + CommentId = old.SequentialId, + Content = old.Content, + Publisher = ApiMinimalUserResponse.FromOld(old.Author, dataContext)!, + Profile = ApiMinimalUserResponse.FromOld(old.Profile, dataContext)!, + Rating = ApiRatingResponse.FromRating + ( + dataContext.Database.GetTotalRatingsForProfileComment(old, RatingType.Yay), + dataContext.Database.GetTotalRatingsForProfileComment(old, RatingType.Boo), + dataContext.User != null ? (int?)dataContext.Database.GetProfileCommentRatingByUser(old, dataContext.User) : 0 + ), + Timestamp = old.Timestamp, + }; + } + + public static IEnumerable FromOldList(IEnumerable oldList, DataContext dataContext) + => oldList.Select(old => FromOld(old, dataContext)).ToList()!; +} \ No newline at end of file diff --git a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Data/ApiRatingResponse.cs b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Data/ApiRatingResponse.cs new file mode 100644 index 000000000..c9da9bc54 --- /dev/null +++ b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Data/ApiRatingResponse.cs @@ -0,0 +1,19 @@ +namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Data; + +[JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))] +public class ApiRatingResponse : IApiResponse +{ + public required int YayRatings { get; set; } + public required int BooRatings { get; set; } + public required int OwnRating { get; set; } + + public static ApiRatingResponse FromRating(int yayRatings, int booRatings, int? ownRating) + { + return new() + { + YayRatings = yayRatings, + BooRatings = booRatings, + OwnRating = ownRating ?? 0, + }; + } +} \ No newline at end of file diff --git a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Levels/ApiGameLevelResponse.cs b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Levels/ApiGameLevelResponse.cs index e90107ce8..142c86536 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Levels/ApiGameLevelResponse.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Levels/ApiGameLevelResponse.cs @@ -5,6 +5,7 @@ using Refresh.Database.Models.Levels; using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Data; using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Users; +using Refresh.Interfaces.APIv3.Extensions; namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Levels; @@ -72,7 +73,7 @@ public class ApiGameLevelResponse : IApiResponse, IDataConvertableFrom FromOldList(IEnumerable oldList, DataContext dataContext) => oldList.Select(old => FromOld(old, dataContext)).ToList()!; } \ No newline at end of file diff --git a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Levels/ApiMinimalLevelResponse.cs b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Levels/ApiMinimalLevelResponse.cs new file mode 100644 index 000000000..d4687b83a --- /dev/null +++ b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Levels/ApiMinimalLevelResponse.cs @@ -0,0 +1,29 @@ +using Refresh.Core.Types.Data; +using Refresh.Database.Models.Authentication; +using Refresh.Database.Models.Levels; +using Refresh.Interfaces.APIv3.Extensions; + +namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Levels; + +[JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))] +public class ApiMinimalLevelResponse : IApiResponse, IDataConvertableFrom +{ + public required int LevelId { get; set; } + public required string Title { get; set; } + public required string IconHash { get; set; } + + public static ApiMinimalLevelResponse? FromOld(GameLevel? level, DataContext dataContext) + { + if (level == null) return null; + + return new ApiMinimalLevelResponse + { + LevelId = level.LevelId, + Title = level.Title, + IconHash = level.GetIconHash(dataContext), + }; + } + + public static IEnumerable FromOldList(IEnumerable oldList, DataContext dataContext) + => oldList.Select(old => FromOld(old, dataContext)).ToList()!; +} \ No newline at end of file diff --git a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiMinimalUserResponse.cs b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiMinimalUserResponse.cs new file mode 100644 index 000000000..9c428b982 --- /dev/null +++ b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiMinimalUserResponse.cs @@ -0,0 +1,28 @@ +using Refresh.Core.Types.Data; +using Refresh.Database.Models.Users; + +namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Users; + +[JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))] +public class ApiMinimalUserResponse : IApiResponse, IDataConvertableFrom +{ + public required string UserId { get; set; } + public required string Username { get; set; } + public required string IconHash { get; set; } + + + public static ApiMinimalUserResponse? FromOld(GameUser? old, DataContext dataContext) + { + if (old == null) return null; + + return new ApiMinimalUserResponse + { + UserId = old.UserId.ToString(), + Username = old.Username, + IconHash = old.IconHash, + }; + } + + public static IEnumerable FromOldList(IEnumerable oldList, DataContext dataContext) + => oldList.Select(old => FromOld(old, dataContext)).ToList()!; +} \ No newline at end of file diff --git a/Refresh.Interfaces.APIv3/Extensions/GameLevelExtensions.cs b/Refresh.Interfaces.APIv3/Extensions/GameLevelExtensions.cs new file mode 100644 index 000000000..450c7f922 --- /dev/null +++ b/Refresh.Interfaces.APIv3/Extensions/GameLevelExtensions.cs @@ -0,0 +1,16 @@ +using Refresh.Core.Types.Data; +using Refresh.Database.Models.Authentication; +using Refresh.Database.Models.Levels; + +namespace Refresh.Interfaces.APIv3.Extensions; + +public static class GameLevelExtensions +{ + public static string GetIconHash(this GameLevel level, DataContext dataContext) + { + string hash = dataContext.Database.GetAssetFromHash(level.IconHash)?.GetAsIcon(TokenGame.Website, dataContext) ?? level.IconHash; + return level.GameVersion == TokenGame.LittleBigPlanetPSP + ? "psp/" + hash + : hash; + } +} diff --git a/Refresh.Interfaces.Game/Endpoints/CommentEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/CommentEndpoints.cs index d20a4c290..69be31057 100644 --- a/Refresh.Interfaces.Game/Endpoints/CommentEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/CommentEndpoints.cs @@ -53,7 +53,8 @@ public Response PostProfileComment(RequestContext context, GameDatabaseContext d (int skip, int count) = context.GetPageData(); - return new SerializedCommentList(SerializedComment.FromOldList(database.GetProfileComments(profile, count, skip).ToArray(), dataContext)); + DatabaseList comments = database.GetProfileComments(profile, count, skip); + return new SerializedCommentList(SerializedComment.FromOldList(comments.Items.ToArray(), dataContext)); } [GameEndpoint("deleteUserComment/{username}", HttpMethods.Post)] @@ -115,7 +116,8 @@ public Response PostLevelComment(RequestContext context, GameDatabaseContext dat (int skip, int count) = context.GetPageData(); - return new SerializedCommentList(SerializedComment.FromOldList(database.GetLevelComments(level, count, skip).ToArray(), dataContext)); + DatabaseList comments = database.GetLevelComments(level, count, skip); + return new SerializedCommentList(SerializedComment.FromOldList(comments.Items.ToArray(), dataContext)); } [GameEndpoint("deleteComment/{slotType}/{id}", HttpMethods.Post)] @@ -151,9 +153,7 @@ public Response RateProfileComment(RequestContext context, GameDatabaseContext d if (comment == null) return NotFound; - if (!database.RateProfileComment(user, comment, ratingType)) - return BadRequest; - + database.RateProfileComment(user, comment, ratingType); return OK; } @@ -168,9 +168,7 @@ public Response RateLevelComment(RequestContext context, GameDatabaseContext dat if (comment == null) return NotFound; - if (!database.RateLevelComment(user, comment, ratingType)) - return BadRequest; - + database.RateLevelComment(user, comment, ratingType); return OK; } } \ No newline at end of file diff --git a/RefreshTests.GameServer/Tests/ApiV3/CommentApiTests.cs b/RefreshTests.GameServer/Tests/ApiV3/CommentApiTests.cs new file mode 100644 index 000000000..475ce1cc0 --- /dev/null +++ b/RefreshTests.GameServer/Tests/ApiV3/CommentApiTests.cs @@ -0,0 +1,289 @@ +using Refresh.Database.Models.Authentication; +using Refresh.Database.Models.Comments; +using Refresh.Database.Models.Levels; +using Refresh.Database.Models.Users; +using Refresh.Interfaces.APIv3.Endpoints.ApiTypes; +using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Request; +using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Comments; +using RefreshTests.GameServer.Extensions; + +namespace RefreshTests.GameServer.Tests.ApiV3; + +public class CommentApiTests : GameServerTest +{ + [Test] + public void PostLevelComments() + { + using TestContext context = this.GetServer(); + GameUser publisher = context.CreateUser(); + GameLevel level = context.CreateLevel(publisher); + int id = level.LevelId; + const int commentsToPostCount = 4; + + for (int i = 0; i < commentsToPostCount; i++) + { + GameUser newCommentPoster = context.CreateUser(); + using HttpClient client = context.GetAuthenticatedClient(TokenType.Api, newCommentPoster); + + ApiCommentPostRequest commentToPost = new() + { + Content = "This level stinks" + }; + ApiResponse? response = client.PostData($"/api/v3/levels/id/{id}/comments", commentToPost); + + // Check the returned comment's attributes + Assert.That(response?.Data, Is.Not.Null); + Assert.That(response!.Success, Is.True); + Assert.That(response.Data!.Publisher.UserId, Is.EqualTo(newCommentPoster.UserId.ToString())); + Assert.That(response.Data!.Level.LevelId, Is.EqualTo(id)); + Assert.That(response.Data!.Content, Is.EqualTo(commentToPost.Content)); + } + + // Now get the comments + ApiListResponse? comments = context.Http.GetList($"/api/v3/levels/id/{id}/comments"); + Assert.That(comments?.Data, Is.Not.Null); + Assert.That(comments!.Success, Is.True); + Assert.That(comments.Data!, Has.Count.EqualTo(commentsToPostCount)); + } + + [Test] + public void PostProfileComments() + { + using TestContext context = this.GetServer(); + GameUser profile = context.CreateUser(); + string uuid = profile.UserId.ToString(); + const int commentsToPostCount = 4; + + for (int i = 0; i < commentsToPostCount; i++) + { + GameUser newCommentPoster = context.CreateUser(); + using HttpClient client = context.GetAuthenticatedClient(TokenType.Api, newCommentPoster); + + ApiCommentPostRequest commentToPost = new() + { + Content = "Hi lol" + }; + ApiResponse? response = client.PostData($"/api/v3/users/uuid/{uuid}/comments", commentToPost); + + // Check the returned comment's attributes + Assert.That(response?.Data, Is.Not.Null); + Assert.That(response!.Success, Is.True); + Assert.That(response.Data!.Publisher.UserId, Is.EqualTo(newCommentPoster.UserId.ToString())); + Assert.That(response.Data!.Profile.UserId, Is.EqualTo(uuid)); + Assert.That(response.Data!.Content, Is.EqualTo(commentToPost.Content)); + } + + // Now get the comments + ApiListResponse? comments = context.Http.GetList($"/api/v3/users/uuid/{uuid}/comments"); + Assert.That(comments?.Data, Is.Not.Null); + Assert.That(comments!.Success, Is.True); + Assert.That(comments.Data!, Has.Count.EqualTo(commentsToPostCount)); + } + + [Test] + public async Task DeleteLevelCommentAsCommentPublisher() + { + using TestContext context = this.GetServer(); + GameUser commentPublisher = context.CreateUser(); + GameUser levelPublisher = context.CreateUser(); + GameLevel level = context.CreateLevel(levelPublisher); + + // Create and delete comment using its ID + GameLevelComment comment = context.Database.PostCommentToLevel(level, commentPublisher, "Would be funny if i spoiled this level's ending"); + int id = comment.SequentialId; + + using HttpClient client = context.GetAuthenticatedClient(TokenType.Api, commentPublisher); + HttpResponseMessage response = await client.DeleteAsync($"/api/v3/levelComments/id/{id}"); + Assert.That(response.IsSuccessStatusCode, Is.True); + Assert.That(context.Database.GetLevelCommentById(id), Is.Null); + } + + [Test] + public async Task DeleteLevelCommentAsLevelPublisher() + { + using TestContext context = this.GetServer(); + GameUser publisher = context.CreateUser(); + GameLevel level = context.CreateLevel(publisher); + + // Create and delete comment using its ID + GameLevelComment comment = context.Database.PostCommentToLevel(level, publisher, "h4h"); + int id = comment.SequentialId; + + using HttpClient client = context.GetAuthenticatedClient(TokenType.Api, publisher); + HttpResponseMessage response = await client.DeleteAsync($"/api/v3/levelComments/id/{id}"); + Assert.That(response.IsSuccessStatusCode, Is.True); + Assert.That(context.Database.GetLevelCommentById(id), Is.Null); + } + + [Test] + public async Task CantDeleteLevelCommentIfNotPermitted() + { + using TestContext context = this.GetServer(); + GameUser moron = context.CreateUser(); + GameUser publisher = context.CreateUser(); + GameLevel level = context.CreateLevel(publisher); + + // Create and try to delete comment using its ID + GameLevelComment comment = context.Database.PostCommentToLevel(level, publisher, "This comment is untouchable"); + int id = comment.SequentialId; + + using HttpClient client = context.GetAuthenticatedClient(TokenType.Api, moron); + HttpResponseMessage response = await client.DeleteAsync($"/api/v3/levelComments/id/{id}"); + Assert.That(response.IsSuccessStatusCode, Is.False); + Assert.That(context.Database.GetLevelCommentById(id), Is.Not.Null); + } + + [Test] + public async Task DeleteProfileCommentAsCommentPublisher() + { + using TestContext context = this.GetServer(); + GameUser publisher = context.CreateUser(); + GameUser profile = context.CreateUser(); + + // Create and try to delete comment using its ID + GameProfileComment comment = context.Database.PostCommentToProfile(profile, publisher, "i ran out of funny things to put here"); + int id = comment.SequentialId; + + using HttpClient client = context.GetAuthenticatedClient(TokenType.Api, publisher); + HttpResponseMessage response = await client.DeleteAsync($"/api/v3/profileComments/id/{id}"); + Assert.That(response.IsSuccessStatusCode, Is.True); + Assert.That(context.Database.GetProfileCommentById(id), Is.Null); + } + + [Test] + public async Task DeleteProfileCommentAsProfileOwner() + { + using TestContext context = this.GetServer(); + GameUser publisher = context.CreateUser(); + + // Create and try to delete comment using its ID + GameProfileComment comment = context.Database.PostCommentToProfile(publisher, publisher, "By visiting this profile you agree to..."); + int id = comment.SequentialId; + + using HttpClient client = context.GetAuthenticatedClient(TokenType.Api, publisher); + HttpResponseMessage response = await client.DeleteAsync($"/api/v3/profileComments/id/{id}"); + Assert.That(response.IsSuccessStatusCode, Is.True); + Assert.That(context.Database.GetProfileCommentById(id), Is.Null); + } + + [Test] + public async Task CantDeleteProfileCommentIfNotPermitted() + { + using TestContext context = this.GetServer(); + GameUser moron = context.CreateUser(); + GameUser profile = context.CreateUser(); + + // Create and try to delete comment using its ID + GameProfileComment comment = context.Database.PostCommentToProfile(profile, profile, "im commenting on my own profile im so original"); + int id = comment.SequentialId; + + using HttpClient client = context.GetAuthenticatedClient(TokenType.Api, moron); + HttpResponseMessage response = await client.DeleteAsync($"/api/v3/profileComments/id/{id}"); + Assert.That(response.IsSuccessStatusCode, Is.False); + Assert.That(context.Database.GetProfileCommentById(id), Is.Not.Null); + } + + [Test] + public async Task RateLevelComment() + { + using TestContext context = this.GetServer(); + GameUser rater = context.CreateUser(); + GameUser publisher = context.CreateUser(); + GameLevel level = context.CreateLevel(publisher); + int[] ratePattern = [1, -1, 0, 1, 0, 0, -1]; + + GameLevelComment comment = context.Database.PostCommentToLevel(level, publisher, "We've tested all obstacles and can confirm this level is no-hittable"); + int id = comment.SequentialId; + + using HttpClient client = context.GetAuthenticatedClient(TokenType.Api, rater); + + foreach (int rating in ratePattern) + { + HttpResponseMessage response = await client.PostAsync($"/api/v3/levelComments/id/{id}/rate/{rating}", null); + Assert.That(response.IsSuccessStatusCode, Is.True); + Assert.That(context.Database.GetLevelCommentRatingByUser(comment, rater), rating == 0 ? Is.Null : Is.Not.Null); + + // Check the comment's API response too + ApiResponse? commentResponse = client.GetData($"/api/v3/levelComments/id/{id}"); + Assert.That(commentResponse?.Data, Is.Not.Null); + Assert.That(commentResponse!.Success, Is.True); + Assert.That(commentResponse.Data!.Rating.OwnRating, Is.EqualTo(rating)); + } + } + + [Test] + [TestCase("-2")] + [TestCase("2")] + [TestCase("0.125")] + [TestCase("1234567")] + [TestCase("-1234567")] + [TestCase("12345678900987654321")] + [TestCase("")] + [TestCase("agree")] + public async Task CantRateLevelCommentWithInvalidRating(string rawRating) + { + using TestContext context = this.GetServer(); + GameUser rater = context.CreateUser(); + GameUser publisher = context.CreateUser(); + GameLevel level = context.CreateLevel(publisher); + + GameLevelComment comment = context.Database.PostCommentToLevel(level, publisher, "play this"); + int id = comment.SequentialId; + + using HttpClient client = context.GetAuthenticatedClient(TokenType.Api, rater); + HttpResponseMessage response = await client.PostAsync($"/api/v3/levelComments/id/{id}/rate/{rawRating}", null); + Assert.That(response.IsSuccessStatusCode, Is.False); + Assert.That(context.Database.GetLevelCommentRatingByUser(comment, rater), Is.Null); + } + + [Test] + public async Task RateProfileComment() + { + using TestContext context = this.GetServer(); + GameUser rater = context.CreateUser(); + GameUser profile = context.CreateUser(); + int[] ratePattern = [1, -1, 0, 1, 0, 0, -1]; + + GameProfileComment comment = context.Database.PostCommentToProfile(profile, profile, "test text"); + int id = comment.SequentialId; + + using HttpClient client = context.GetAuthenticatedClient(TokenType.Api, rater); + + foreach (int rating in ratePattern) + { + HttpResponseMessage response = await client.PostAsync($"/api/v3/profileComments/id/{id}/rate/{rating}", null); + Assert.That(response.IsSuccessStatusCode, Is.True); + Assert.That(context.Database.GetProfileCommentRatingByUser(comment, rater), rating == 0 ? Is.Null : Is.Not.Null); + + // Check the comment's API response too + ApiResponse? commentResponse = client.GetData($"/api/v3/profileComments/id/{id}"); + Assert.That(commentResponse?.Data, Is.Not.Null); + Assert.That(commentResponse!.Success, Is.True); + Assert.That(commentResponse.Data!.Rating.OwnRating, Is.EqualTo(rating)); + } + } + + [Test] + [TestCase("-2")] + [TestCase("2")] + [TestCase("-0.125")] + [TestCase("1234567")] + [TestCase("-1234567")] + [TestCase("1234567890098765420")] + [TestCase("")] + [TestCase("disagree")] + public async Task CantRateProfileCommentWithInvalidRating(string rawRating) + { + using TestContext context = this.GetServer(); + GameUser rater = context.CreateUser(); + GameUser profile = context.CreateUser(); + + GameProfileComment comment = context.Database.PostCommentToProfile(profile, profile, "play my levels"); + int id = comment.SequentialId; + + using HttpClient client = context.GetAuthenticatedClient(TokenType.Api, rater); + HttpResponseMessage response = await client.PostAsync($"/api/v3/profileComments/id/{id}/rate/{rawRating}", null); + Assert.That(response.IsSuccessStatusCode, Is.False); + Assert.That(context.Database.GetProfileCommentRatingByUser(comment, rater), Is.Null); + } +} \ No newline at end of file diff --git a/RefreshTests.GameServer/Tests/Relations/CommentPublishTests.cs b/RefreshTests.GameServer/Tests/Relations/CommentPublishTests.cs index 8a0ff9b7d..ce9ba2de4 100644 --- a/RefreshTests.GameServer/Tests/Relations/CommentPublishTests.cs +++ b/RefreshTests.GameServer/Tests/Relations/CommentPublishTests.cs @@ -14,6 +14,6 @@ public void CanCreateCommentOnProfile() GameProfileComment comment = context.Database.PostCommentToProfile(profile, commenter, "Hi!"); - Assert.That(context.Database.GetProfileComments(profile, 1, 0).First(), Is.EqualTo(comment)); + Assert.That(context.Database.GetProfileComments(profile, 1, 0).Items.First(), Is.EqualTo(comment)); } } \ No newline at end of file