Skip to content

Commit

Permalink
Restore leaderboards (#250)
Browse files Browse the repository at this point in the history
* Create functionality and test

* Followed linter suggestions

* Fix tests

* Return board on restore success

* Add authN/authZ tests

* Add conflict case

* Fix conflict response object type and add more test cases

* Address more comments

* Remove unnecessary DB calls in service method

* Formatting

* Change assertion in test
  • Loading branch information
zysim authored Oct 23, 2024
1 parent f7e15eb commit ac3f493
Show file tree
Hide file tree
Showing 7 changed files with 324 additions and 21 deletions.
178 changes: 176 additions & 2 deletions LeaderboardBackend.Test/Leaderboards.cs
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Threading;
using System.Net.Http.Json;
using System.Threading.Tasks;
using FluentAssertions.Specialized;
using LeaderboardBackend.Models.Entities;
using LeaderboardBackend.Models.Requests;
using LeaderboardBackend.Models.ViewModels;
using LeaderboardBackend.Services;
using LeaderboardBackend.Test.Lib;
using LeaderboardBackend.Test.TestApi;
using LeaderboardBackend.Test.TestApi.Extensions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
Expand Down Expand Up @@ -348,4 +350,176 @@ public async Task GetLeaderboards()
LeaderboardViewModel[] returned = await _apiClient.Get<LeaderboardViewModel[]>("/api/leaderboards", new());
returned.Should().BeEquivalentTo(boards.Take(2), config => config.Excluding(lb => lb.Categories));
}

[Test]
public async Task RestoreLeaderboard_OK()
{
ApplicationContext context = _factory.Services.CreateScope().ServiceProvider.GetRequiredService<ApplicationContext>();

Leaderboard deletedBoard = new()
{
Name = "Super Mario World",
Slug = "super-mario-world-to-restore",
DeletedAt = _clock.GetCurrentInstant()
};

context.Leaderboards.Add(deletedBoard);
await context.SaveChangesAsync();
deletedBoard.Id.Should().NotBe(default);

_clock.AdvanceMinutes(1);

LeaderboardViewModel res = await _apiClient.Put<LeaderboardViewModel>($"/leaderboard/{deletedBoard.Id}/restore", new()
{
Jwt = _jwt
});

res.Id.Should().Be(deletedBoard.Id);
res.Slug.Should().Be(deletedBoard.Slug);
res.UpdatedAt.Should().Be(_clock.GetCurrentInstant());
res.DeletedAt.Should().BeNull();
}

[Test]
public async Task RestoreLeaderboard_Unauthenticated()
{
Func<Task<LeaderboardViewModel>> act = async () => await _apiClient.Put<LeaderboardViewModel>($"/leaderboard/100/restore", new());

await act.Should().ThrowAsync<RequestFailureException>().Where(e => e.Response.StatusCode == HttpStatusCode.Unauthorized);
}

[Test]
public async Task RestoreLeaderboard_Banned_Unauthorized()
{
string email = "[email protected]";
string password = "P4ssword";

UserViewModel userModel = await _apiClient.RegisterUser(
"RestoreBoardBanned",
email,
password
);

string jwt = (await _apiClient.LoginUser(email, password)).Token;

ApplicationContext context = _factory.Services.CreateScope().ServiceProvider.GetRequiredService<ApplicationContext>();

User update = await context.Users.FirstAsync(user => user.Id == userModel.Id);
update.Role = UserRole.Banned;

await context.SaveChangesAsync();

await FluentActions.Awaiting(
async () => await _apiClient.Put<LeaderboardViewModel>(
$"/leaderboard/1/restore",
new()
{
Jwt = jwt,
}
)
).Should().ThrowAsync<RequestFailureException>()
.Where(e => e.Response.StatusCode == HttpStatusCode.Forbidden);
}

[TestCase("[email protected]", "RestoreBoard1", UserRole.Confirmed)]
[TestCase("[email protected]", "RestoreBoard2", UserRole.Registered)]
public async Task RestoreLeaderboard_Unauthorized(string email, string username, UserRole role)
{
UserViewModel userModel = await _apiClient.RegisterUser(username, email, "P4ssword");

ApplicationContext context = _factory.Services.CreateScope().ServiceProvider.GetRequiredService<ApplicationContext>();

User? user = await context.Users.FindAsync(userModel.Id);

user!.Role = role;

await context.SaveChangesAsync();

string jwt = (await _apiClient.LoginUser(email, "P4ssword")).Token;

Func<Task<LeaderboardViewModel>> act = async () => await _apiClient.Put<LeaderboardViewModel>($"/leaderboard/100/restore", new()
{
Jwt = jwt,
});

await act.Should().ThrowAsync<RequestFailureException>().Where(e => e.Response.StatusCode == HttpStatusCode.Forbidden);
}

[Test]
public async Task RestoreLeaderboard_NotFound()
{
Func<Task<LeaderboardViewModel>> act = async () => await _apiClient.Put<LeaderboardViewModel>($"/leaderboard/{1e10}/restore", new()
{
Jwt = _jwt
});

await act.Should().ThrowAsync<RequestFailureException>().Where(e => e.Response.StatusCode == HttpStatusCode.NotFound);
}

[Test]
public async Task RestoreLeaderboard_NotFound_WasNeverDeleted()
{
ApplicationContext context = _factory.Services.CreateScope().ServiceProvider.GetRequiredService<ApplicationContext>();

Leaderboard board = new()
{
Name = "Hyper Mario World Not Deleted",
Slug = "hyper-mario-world-non-deleted",
};

context.Leaderboards.Add(board);
await context.SaveChangesAsync();
board.Id.Should().NotBe(default);

ExceptionAssertions<RequestFailureException> exAssert = await FluentActions.Awaiting(() =>
_apiClient.Put<LeaderboardViewModel>(
$"/leaderboard/{board.Id}/restore",
new()
{
Jwt = _jwt,
}
)
).Should().ThrowAsync<RequestFailureException>().Where(ex => ex.Response.StatusCode == HttpStatusCode.NotFound);

ProblemDetails? problemDetails = await exAssert.Which.Response.Content.ReadFromJsonAsync<ProblemDetails>(TestInitCommonFields.JsonSerializerOptions);
problemDetails.Should().NotBeNull();
problemDetails!.Title.Should().Be("Not Deleted");
}

[Test]
public async Task RestoreLeaderboard_Conflict()
{
ApplicationContext context = _factory.Services.CreateScope().ServiceProvider.GetRequiredService<ApplicationContext>();

Leaderboard deleted = new()
{
Name = "Conflicted Mario World",
Slug = "conflicted-mario-world",
DeletedAt = _clock.GetCurrentInstant()
};

Leaderboard reclaimed = new()
{
Name = "Reclaimed Mario World",
Slug = "conflicted-mario-world",
};

context.Leaderboards.Add(deleted);
context.Leaderboards.Add(reclaimed);
await context.SaveChangesAsync();

ExceptionAssertions<RequestFailureException> exAssert = await FluentActions.Awaiting(() =>
_apiClient.Put<LeaderboardViewModel>(
$"/leaderboard/{deleted.Id}/restore",
new()
{
Jwt = _jwt,
}
)
).Should().ThrowAsync<RequestFailureException>().Where(ex => ex.Response.StatusCode == HttpStatusCode.Conflict);

LeaderboardViewModel? model = await exAssert.Which.Response.Content.ReadFromJsonAsync<LeaderboardViewModel>(TestInitCommonFields.JsonSerializerOptions);
model.Should().NotBeNull();
model!.Id.Should().Be(reclaimed.Id);
}
}
29 changes: 12 additions & 17 deletions LeaderboardBackend.Test/TestApi/TestApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,20 +36,17 @@ public TestApiClient(HttpClient client)
_client = client;
}

public async Task<TResponse> Get<TResponse>(string endpoint, HttpRequestInit init)
{
return await SendAndRead<TResponse>(endpoint, init with { Method = HttpMethod.Get });
}
public async Task<TResponse> Get<TResponse>(string endpoint, HttpRequestInit init) =>
await SendAndRead<TResponse>(endpoint, init with { Method = HttpMethod.Get });

public async Task<TResponse> Post<TResponse>(string endpoint, HttpRequestInit init)
{
return await SendAndRead<TResponse>(endpoint, init with { Method = HttpMethod.Post });
}
public async Task<TResponse> Post<TResponse>(string endpoint, HttpRequestInit init) =>
await SendAndRead<TResponse>(endpoint, init with { Method = HttpMethod.Post });

public async Task<HttpResponseMessage> Delete(string endpoint, HttpRequestInit init)
{
return await Send(endpoint, init with { Method = HttpMethod.Delete });
}
public async Task<TResponse> Put<TResponse>(string endpoint, HttpRequestInit init) =>
await SendAndRead<TResponse>(endpoint, init with { Method = HttpMethod.Put });

public async Task<HttpResponseMessage> Delete(string endpoint, HttpRequestInit init) =>
await Send(endpoint, init with { Method = HttpMethod.Delete });

private async Task<TResponse> SendAndRead<TResponse>(string endpoint, HttpRequestInit init)
{
Expand Down Expand Up @@ -77,7 +74,7 @@ private static async Task<T> ReadFromResponseBody<T>(HttpResponseMessage respons
string rawJson = await response.Content.ReadAsStringAsync();
T? obj = JsonSerializer.Deserialize<T>(rawJson, TestInitCommonFields.JsonSerializerOptions);

Assert.NotNull(obj);
obj.Should().NotBeNull();

return obj!;
}
Expand All @@ -86,9 +83,8 @@ private static HttpRequestMessage CreateRequestMessage(
string endpoint,
HttpRequestInit init,
JsonSerializerOptions options
)
{
return new(init.Method, endpoint)
) =>
new(init.Method, endpoint)
{
Headers = { Authorization = new(JwtBearerDefaults.AuthenticationScheme, init.Jwt) },
Content = init.Body switch
Expand All @@ -101,5 +97,4 @@ not null
_ => default
}
};
}
}
25 changes: 24 additions & 1 deletion LeaderboardBackend/Controllers/LeaderboardsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public async Task<ActionResult<LeaderboardViewModel>> GetLeaderboard(long id)

[AllowAnonymous]
[HttpGet("api/leaderboard")]
[SwaggerOperation("Gets a Leaderboard by its slug.", OperationId = "getLeaderboardBySlug")]
[SwaggerOperation("Gets a leaderboard by its slug.", OperationId = "getLeaderboardBySlug")]
[SwaggerResponse(200)]
[SwaggerResponse(404)]
public async Task<ActionResult<LeaderboardViewModel>> GetLeaderboardBySlug([FromQuery, SwaggerParameter(Required = true)] string slug)
Expand Down Expand Up @@ -86,4 +86,27 @@ public async Task<ActionResult<LeaderboardViewModel>> CreateLeaderboard(
}
);
}

[Authorize(Policy = UserTypes.ADMINISTRATOR)]
[HttpPut("leaderboard/{id:long}/restore")]
[SwaggerOperation("Restores a deleted leaderboard.", OperationId = "restoreLeaderboard")]
[SwaggerResponse(200, "The restored `Leaderboard`s view model.", typeof(LeaderboardViewModel))]
[SwaggerResponse(401)]
[SwaggerResponse(403, "The requesting `User` is unauthorized to restore `Leaderboard`s.")]
[SwaggerResponse(404, "The `Leaderboard` was not found, or it wasn't deleted in the first place. Includes a field, `title`, which will be \"Not Found\" in the former case, and \"Not Deleted\" in the latter.", typeof(ProblemDetails))]
[SwaggerResponse(409, "Another `Leaderboard` with the same slug has been created since, and therefore can't be restored. Will include the conflicting board in the response.", typeof(LeaderboardViewModel))]
public async Task<ActionResult<LeaderboardViewModel>> RestoreLeaderboard(
long id
)
{
RestoreLeaderboardResult r = await leaderboardService.RestoreLeaderboard(id);

return r.Match<ActionResult<LeaderboardViewModel>>(
board => Ok(LeaderboardViewModel.MapFrom(board)),
notFound => NotFound(),
neverDeleted =>
NotFound(ProblemDetailsFactory.CreateProblemDetails(HttpContext, 404, "Not Deleted")),
conflict => Conflict(LeaderboardViewModel.MapFrom(conflict.Board))
);
}
}
5 changes: 5 additions & 0 deletions LeaderboardBackend/Results.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using LeaderboardBackend.Models.Entities;

namespace LeaderboardBackend.Result;

public readonly record struct AccountConfirmed;
Expand All @@ -8,5 +10,8 @@ namespace LeaderboardBackend.Result;
public readonly record struct EmailFailed;
public readonly record struct Expired;
public readonly record struct CreateLeaderboardConflict;
public readonly record struct LeaderboardNotFound;
public readonly record struct LeaderboardNeverDeleted;
public readonly record struct RestoreLeaderboardConflict(Leaderboard Board);
public readonly record struct UserNotFound;
public readonly record struct UserBanned;
4 changes: 4 additions & 0 deletions LeaderboardBackend/Services/ILeaderboardService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ public interface ILeaderboardService
Task<Leaderboard?> GetLeaderboardBySlug(string slug);
Task<List<Leaderboard>> ListLeaderboards();
Task<CreateLeaderboardResult> CreateLeaderboard(CreateLeaderboardRequest request);
Task<RestoreLeaderboardResult> RestoreLeaderboard(long id);
}

[GenerateOneOf]
public partial class CreateLeaderboardResult : OneOfBase<Leaderboard, CreateLeaderboardConflict>;

[GenerateOneOf]
public partial class RestoreLeaderboardResult : OneOfBase<Leaderboard, LeaderboardNotFound, LeaderboardNeverDeleted, RestoreLeaderboardConflict>;
31 changes: 31 additions & 0 deletions LeaderboardBackend/Services/Impl/LeaderboardService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using LeaderboardBackend.Models.Requests;
using LeaderboardBackend.Result;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using Npgsql;

namespace LeaderboardBackend.Services;
Expand Down Expand Up @@ -42,4 +43,34 @@ public async Task<CreateLeaderboardResult> CreateLeaderboard(CreateLeaderboardRe

return lb;
}

public async Task<RestoreLeaderboardResult> RestoreLeaderboard(long id)
{
Leaderboard? lb = await applicationContext.Leaderboards.FindAsync(id);

if (lb == null)
{
return new LeaderboardNotFound();
}

if (lb.DeletedAt == null)
{
return new LeaderboardNeverDeleted();
}

lb.DeletedAt = null;

try
{
await applicationContext.SaveChangesAsync();
}
catch (DbUpdateException e)
when (e.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation } pgEx)
{
Leaderboard conflict = await applicationContext.Leaderboards.SingleAsync(c => c.Slug == lb.Slug && c.DeletedAt == null);
return new RestoreLeaderboardConflict(conflict);
}

return lb;
}
}
Loading

0 comments on commit ac3f493

Please sign in to comment.