Skip to content

Commit

Permalink
Implement Comment ratings (#425)
Browse files Browse the repository at this point in the history
This PR implements the ability to rate profile and level comments
through the game API.

Closes #70


![image](https://github.com/LittleBigRefresh/Refresh/assets/51852312/17be559d-d3f4-4af8-aac2-e7529f2bd5ff)
  • Loading branch information
jvyden authored Apr 23, 2024
2 parents 103f0d1 + d701b47 commit f1717de
Show file tree
Hide file tree
Showing 9 changed files with 211 additions and 10 deletions.
2 changes: 2 additions & 0 deletions Refresh.GameServer/Database/GameDatabaseContext.Comments.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ namespace Refresh.GameServer.Database;

public partial class GameDatabaseContext // Comments
{
public GameComment? GetCommentById(int id) =>
this._realm.All<GameComment>().FirstOrDefault(c => c.SequentialId == id);
public GameComment PostCommentToProfile(GameUser profile, GameUser author, string content)
{
GameComment comment = new()
Expand Down
53 changes: 53 additions & 0 deletions Refresh.GameServer/Database/GameDatabaseContext.Relations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Refresh.GameServer.Authentication;
using Refresh.GameServer.Endpoints.Game.Levels.FilterSettings;
using Refresh.GameServer.Extensions;
using Refresh.GameServer.Types.Comments;
using Refresh.GameServer.Types.Levels;
using Refresh.GameServer.Types.Relations;
using Refresh.GameServer.Types.Reviews;
Expand Down Expand Up @@ -305,4 +306,56 @@ public bool HasUserPlayedLevel(GameLevel level, GameUser user) =>
.FirstOrDefault(r => r.Level == level && r.User == user) != null;

#endregion

#region Comments

private CommentRelation? GetCommentRelationByUser(GameComment comment, GameUser user) => this._realm
.All<CommentRelation>().FirstOrDefault(r => r.Comment == comment && r.User == user);

/// <summary>
/// Get a user's rating on a particular comment.
/// A null return value means a user has not set a rating.
/// </summary>
/// <param name="comment">The comment to check</param>
/// <param name="user">The user to check</param>
/// <returns>The rating if found</returns>
[Pure]
public RatingType? GetRatingByUser(GameComment comment, GameUser user)
=> this.GetCommentRelationByUser(comment, user)?.RatingType;

public bool RateComment(GameUser user, GameComment comment, RatingType ratingType)
{
if (ratingType == RatingType.Neutral)
return false;

CommentRelation? relation = GetCommentRelationByUser(comment, user);

if (relation == null)
{
relation = new CommentRelation
{
User = user,
Comment = comment,
Timestamp = this._time.Now,
RatingType = ratingType,
};

this._realm.Write(() =>
{
this._realm.Add(relation);
});
}
else
{
this._realm.Write(() =>
{
relation.Timestamp = this._time.Now;
relation.RatingType = ratingType;
});
}

return true;
}

#endregion
}
5 changes: 3 additions & 2 deletions Refresh.GameServer/Database/GameDatabaseProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ protected GameDatabaseProvider(IDateTimeProvider time)
this._time = time;
}

protected override ulong SchemaVersion => 120;
protected override ulong SchemaVersion => 121;

protected override string Filename => "refreshGameServer.realm";

Expand All @@ -47,6 +47,7 @@ protected GameDatabaseProvider(IDateTimeProvider time)
typeof(GameLevel),
typeof(GameSkillReward),
typeof(GameComment),
typeof(CommentRelation),
typeof(FavouriteLevelRelation),
typeof(QueueLevelRelation),
typeof(FavouriteUserRelation),
Expand Down Expand Up @@ -76,7 +77,7 @@ protected GameDatabaseProvider(IDateTimeProvider time)
typeof(ScreenRect),
typeof(Slot),
typeof(GameReview),
typeof(DisallowedUser)
typeof(DisallowedUser),
};

public override void Warmup()
Expand Down
28 changes: 24 additions & 4 deletions Refresh.GameServer/Endpoints/Game/CommentEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Refresh.GameServer.Types.Comments;
using Refresh.GameServer.Types.Levels;
using Refresh.GameServer.Types.Lists;
using Refresh.GameServer.Types.Reviews;
using Refresh.GameServer.Types.Roles;
using Refresh.GameServer.Types.UserData;

Expand All @@ -34,15 +35,15 @@ public Response PostProfileComment(RequestContext context, GameDatabaseContext d
[GameEndpoint("userComments/{username}", ContentType.Xml)]
[NullStatusCode(NotFound)]
[MinimumRole(GameUserRole.Restricted)]
public SerializedCommentList? GetProfileComments(RequestContext context, GameDatabaseContext database, string username)
public SerializedCommentList? GetProfileComments(RequestContext context, GameDatabaseContext database, GameUser user, string username)
{
GameUser? profile = database.GetUserByUsername(username);
if (profile == null) return null;

(int skip, int count) = context.GetPageData();

List<GameComment> comments = database.GetProfileComments(profile, count, skip).ToList();
foreach (GameComment comment in comments) comment.PrepareForSerialization();
foreach (GameComment comment in comments) comment.PrepareForSerialization(user);

return new SerializedCommentList(comments);
}
Expand Down Expand Up @@ -88,15 +89,15 @@ public Response PostLevelComment(RequestContext context, GameDatabaseContext dat
[GameEndpoint("comments/{slotType}/{id}", ContentType.Xml)]
[NullStatusCode(NotFound)]
[MinimumRole(GameUserRole.Restricted)]
public SerializedCommentList? GetLevelComments(RequestContext context, GameDatabaseContext database, string slotType, int id)
public SerializedCommentList? GetLevelComments(RequestContext context, GameDatabaseContext database, GameUser user, string slotType, int id)
{
GameLevel? level = database.GetLevelByIdAndType(slotType, id);
if (level == null) return null;

(int skip, int count) = context.GetPageData();

List<GameComment> comments = database.GetLevelComments(level, count, skip).ToList();
foreach(GameComment comment in comments) comment.PrepareForSerialization();
foreach(GameComment comment in comments) comment.PrepareForSerialization(user);

return new SerializedCommentList(comments);
}
Expand All @@ -123,4 +124,23 @@ public Response DeleteLevelComment(RequestContext context, GameDatabaseContext d

return OK;
}


[GameEndpoint("rateComment/user/{content}", HttpMethods.Post)] // `user` level comments
[GameEndpoint("rateComment/developer/{content}", HttpMethods.Post)] // `developer` level comments
[GameEndpoint("rateUserComment/{content}", HttpMethods.Post)] // profile comments
public Response RateComment(RequestContext context, GameDatabaseContext database, GameUser user, string content)
{
if (!int.TryParse(context.QueryString["commentId"], out int commentId)) return BadRequest;
if (!Enum.TryParse(context.QueryString["rating"], out RatingType ratingType)) return BadRequest;

GameComment? comment = database.GetCommentById(commentId);
if (comment == null)
return NotFound;

if (!database.RateComment(user, comment, ratingType))
return BadRequest;

return OK;
}
}
15 changes: 11 additions & 4 deletions Refresh.GameServer/Types/Comments/GameComment.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
using System.Xml.Serialization;
using Realms;
using Refresh.GameServer.Database;
using Refresh.GameServer.Types.Relations;
using Refresh.GameServer.Types.Reviews;
using Refresh.GameServer.Types.UserData;
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.

namespace Refresh.GameServer.Types.Comments;

Expand All @@ -18,6 +21,9 @@ public partial class GameComment : IRealmObject, ISequentialId
/// Timestamp in Unix milliseconds
/// </summary>
[XmlElement("timestamp")] public long Timestamp { get; set; }

[Backlink(nameof(CommentRelation.Comment))]
public IQueryable<CommentRelation> CommentRelations { get; }

#region LBP Serialization Quirks

Expand All @@ -27,14 +33,15 @@ public partial class GameComment : IRealmObject, ISequentialId
[XmlElement("thumbsdown")] [Ignored] public int? ThumbsDown { get; set; }
[XmlElement("yourthumb")] [Ignored] public int? YourThumb { get; set; }

public void PrepareForSerialization()
public void PrepareForSerialization(GameUser user)
{
this.Handle = this.Author.Username;

this.ThumbsUp = 0;
this.ThumbsDown = 0;
this.ThumbsUp = this.CommentRelations.Count(r => r._RatingType == (int)RatingType.Yay);
this.ThumbsDown = this.CommentRelations.Count(r => r._RatingType == (int)RatingType.Boo);

this.YourThumb = 0;
this.YourThumb = (int?)(this.CommentRelations
.FirstOrDefault(r => r.User == user)?.RatingType ?? 0);
}

#endregion
Expand Down
25 changes: 25 additions & 0 deletions Refresh.GameServer/Types/Relations/CommentRelation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using MongoDB.Bson;
using Realms;
using Refresh.GameServer.Types.Comments;
using Refresh.GameServer.Types.Reviews;
using Refresh.GameServer.Types.UserData;

namespace Refresh.GameServer.Types.Relations;
#nullable disable

public partial class CommentRelation : IRealmObject
{
public ObjectId CommentRelationId { get; set; } = ObjectId.GenerateNewId();
public GameUser User { get; set; }
public GameComment Comment { get; set; }
[Ignored]
public RatingType RatingType
{
get => (RatingType)this._RatingType;
set => this._RatingType = (int)value;
}

// ReSharper disable once InconsistentNaming
internal int _RatingType { get; set; }
public DateTimeOffset Timestamp { get; set; }
}
59 changes: 59 additions & 0 deletions RefreshTests.GameServer/Tests/Comments/CommentTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using Refresh.GameServer.Types.Comments;
using Refresh.GameServer.Types.Lists;
using Refresh.GameServer.Types.Reviews;
using Refresh.GameServer.Types.UserData;
using RefreshTests.GameServer.Extensions;
using TokenType = Refresh.GameServer.Authentication.TokenType;

namespace RefreshTests.GameServer.Tests.Comments;

public class CommentTests : GameServerTest
{
public static void RateComment(TestContext context, GameUser user, GameComment comment, string rateCommentUrl, string getCommentsUrl)
{
using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user);


foreach (RatingType ratingType in new List<RatingType> {RatingType.Neutral, RatingType.Boo, RatingType.Yay})
{
for (int i = 0; i < 3; i++) // Rate multiple times to test that duplicate ratings are not added
{
// ReSharper disable once RedundantAssignment
client.PostAsync($"{rateCommentUrl}?commentId={comment.SequentialId}&rating={ratingType.ToDPad()}", null);
}

HttpResponseMessage response = client.GetAsync(getCommentsUrl).Result;
SerializedCommentList userComments = response.Content.ReadAsXML<SerializedCommentList>();
comment = userComments.Items.First();

int expectedThumbsUp, expectedThumbsDown;

switch (ratingType)
{
case RatingType.Neutral:
expectedThumbsDown = 0;
expectedThumbsUp = 0;
break;
case RatingType.Boo:
expectedThumbsDown = 1;
expectedThumbsUp = 0;
break;
case RatingType.Yay:
expectedThumbsDown = 0;
expectedThumbsUp = 1;
break;
default:
throw new ArgumentOutOfRangeException();
}

Assert.Multiple(() =>
{
Assert.That(comment.YourThumb, Is.EqualTo(ratingType.ToDPad()));
Assert.That(comment.ThumbsUp, Is.EqualTo(expectedThumbsUp));
Assert.That(comment.ThumbsDown, Is.EqualTo(expectedThumbsDown));
});
}


}
}
24 changes: 24 additions & 0 deletions RefreshTests.GameServer/Tests/Comments/LevelCommentTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -155,4 +155,28 @@ public void CantDeleteAnotherUsersComment()
response = client2.PostAsync($"/lbp/deleteComment/user/{level.LevelId}?commentId={userComments.Items[0].SequentialId}", new ByteArrayContent(Array.Empty<byte>())).Result;
Assert.That(response.StatusCode, Is.EqualTo(Unauthorized));
}

[Test]
public void RateUserLevelComment()
{
using TestContext context = this.GetServer();
GameUser user = context.CreateUser();
GameLevel level = context.CreateLevel(user);
GameComment comment = context.Database.PostCommentToLevel(level, user, "This is a test comment!");

CommentTests.RateComment(context, user, comment, $"/lbp/rateComment/user/{level.LevelId}", $"/lbp/comments/user/{level.LevelId}");
}

[Test]
public void RateDeveloperLevelComment()
{
const int levelId = 1;

using TestContext context = this.GetServer();
GameUser user = context.CreateUser();
GameLevel level = context.Database.GetStoryLevelById(levelId);
GameComment comment = context.Database.PostCommentToLevel(level, user, "This is a test comment!");

CommentTests.RateComment(context, user, comment, $"/lbp/rateComment/developer/{level.LevelId}", $"/lbp/comments/developer/{level.LevelId}");
}
}
10 changes: 10 additions & 0 deletions RefreshTests.GameServer/Tests/Comments/UserCommentTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -151,4 +151,14 @@ public void CantDeleteAnotherUsersComment()
response = client2.PostAsync($"/lbp/deleteUserComment/{user2.Username}?commentId={userComments.Items[0].SequentialId}", new ByteArrayContent(Array.Empty<byte>())).Result;
Assert.That(response.StatusCode, Is.EqualTo(Unauthorized));
}

[Test]
public void RateProfileComment()
{
using TestContext context = this.GetServer();
GameUser user = context.CreateUser();
GameComment comment = context.Database.PostCommentToProfile(user, user, "This is a test comment!");

CommentTests.RateComment(context, user, comment, $"/lbp/rateUserComment/{user.Username}", $"/lbp/userComments/{user.Username}");
}
}

0 comments on commit f1717de

Please sign in to comment.