diff --git a/LeaderboardBackend.Test/Leaderboards.cs b/LeaderboardBackend.Test/Leaderboards.cs index 1bb4642a..17c7830b 100644 --- a/LeaderboardBackend.Test/Leaderboards.cs +++ b/LeaderboardBackend.Test/Leaderboards.cs @@ -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; @@ -348,4 +350,176 @@ public async Task GetLeaderboards() LeaderboardViewModel[] returned = await _apiClient.Get("/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(); + + 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($"/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> act = async () => await _apiClient.Put($"/leaderboard/100/restore", new()); + + await act.Should().ThrowAsync().Where(e => e.Response.StatusCode == HttpStatusCode.Unauthorized); + } + + [Test] + public async Task RestoreLeaderboard_Banned_Unauthorized() + { + string email = "restore-leaderboard-banned@example.com"; + 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(); + + 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( + $"/leaderboard/1/restore", + new() + { + Jwt = jwt, + } + ) + ).Should().ThrowAsync() + .Where(e => e.Response.StatusCode == HttpStatusCode.Forbidden); + } + + [TestCase("restore-leaderboard-unauth1@example.com", "RestoreBoard1", UserRole.Confirmed)] + [TestCase("restore-leaderboard-unauth2@example.com", "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(); + + User? user = await context.Users.FindAsync(userModel.Id); + + user!.Role = role; + + await context.SaveChangesAsync(); + + string jwt = (await _apiClient.LoginUser(email, "P4ssword")).Token; + + Func> act = async () => await _apiClient.Put($"/leaderboard/100/restore", new() + { + Jwt = jwt, + }); + + await act.Should().ThrowAsync().Where(e => e.Response.StatusCode == HttpStatusCode.Forbidden); + } + + [Test] + public async Task RestoreLeaderboard_NotFound() + { + Func> act = async () => await _apiClient.Put($"/leaderboard/{1e10}/restore", new() + { + Jwt = _jwt + }); + + await act.Should().ThrowAsync().Where(e => e.Response.StatusCode == HttpStatusCode.NotFound); + } + + [Test] + public async Task RestoreLeaderboard_NotFound_WasNeverDeleted() + { + ApplicationContext context = _factory.Services.CreateScope().ServiceProvider.GetRequiredService(); + + 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 exAssert = await FluentActions.Awaiting(() => + _apiClient.Put( + $"/leaderboard/{board.Id}/restore", + new() + { + Jwt = _jwt, + } + ) + ).Should().ThrowAsync().Where(ex => ex.Response.StatusCode == HttpStatusCode.NotFound); + + ProblemDetails? problemDetails = await exAssert.Which.Response.Content.ReadFromJsonAsync(TestInitCommonFields.JsonSerializerOptions); + problemDetails.Should().NotBeNull(); + problemDetails!.Title.Should().Be("Not Deleted"); + } + + [Test] + public async Task RestoreLeaderboard_Conflict() + { + ApplicationContext context = _factory.Services.CreateScope().ServiceProvider.GetRequiredService(); + + 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 exAssert = await FluentActions.Awaiting(() => + _apiClient.Put( + $"/leaderboard/{deleted.Id}/restore", + new() + { + Jwt = _jwt, + } + ) + ).Should().ThrowAsync().Where(ex => ex.Response.StatusCode == HttpStatusCode.Conflict); + + LeaderboardViewModel? model = await exAssert.Which.Response.Content.ReadFromJsonAsync(TestInitCommonFields.JsonSerializerOptions); + model.Should().NotBeNull(); + model!.Id.Should().Be(reclaimed.Id); + } } diff --git a/LeaderboardBackend.Test/TestApi/TestApiClient.cs b/LeaderboardBackend.Test/TestApi/TestApiClient.cs index 55aa880e..d9403dd5 100644 --- a/LeaderboardBackend.Test/TestApi/TestApiClient.cs +++ b/LeaderboardBackend.Test/TestApi/TestApiClient.cs @@ -36,20 +36,17 @@ public TestApiClient(HttpClient client) _client = client; } - public async Task Get(string endpoint, HttpRequestInit init) - { - return await SendAndRead(endpoint, init with { Method = HttpMethod.Get }); - } + public async Task Get(string endpoint, HttpRequestInit init) => + await SendAndRead(endpoint, init with { Method = HttpMethod.Get }); - public async Task Post(string endpoint, HttpRequestInit init) - { - return await SendAndRead(endpoint, init with { Method = HttpMethod.Post }); - } + public async Task Post(string endpoint, HttpRequestInit init) => + await SendAndRead(endpoint, init with { Method = HttpMethod.Post }); - public async Task Delete(string endpoint, HttpRequestInit init) - { - return await Send(endpoint, init with { Method = HttpMethod.Delete }); - } + public async Task Put(string endpoint, HttpRequestInit init) => + await SendAndRead(endpoint, init with { Method = HttpMethod.Put }); + + public async Task Delete(string endpoint, HttpRequestInit init) => + await Send(endpoint, init with { Method = HttpMethod.Delete }); private async Task SendAndRead(string endpoint, HttpRequestInit init) { @@ -77,7 +74,7 @@ private static async Task ReadFromResponseBody(HttpResponseMessage respons string rawJson = await response.Content.ReadAsStringAsync(); T? obj = JsonSerializer.Deserialize(rawJson, TestInitCommonFields.JsonSerializerOptions); - Assert.NotNull(obj); + obj.Should().NotBeNull(); return obj!; } @@ -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 @@ -101,5 +97,4 @@ not null _ => default } }; - } } diff --git a/LeaderboardBackend/Controllers/LeaderboardsController.cs b/LeaderboardBackend/Controllers/LeaderboardsController.cs index cabf8bbb..039871c5 100644 --- a/LeaderboardBackend/Controllers/LeaderboardsController.cs +++ b/LeaderboardBackend/Controllers/LeaderboardsController.cs @@ -31,7 +31,7 @@ public async Task> 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> GetLeaderboardBySlug([FromQuery, SwaggerParameter(Required = true)] string slug) @@ -86,4 +86,27 @@ public async Task> 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> RestoreLeaderboard( + long id + ) + { + RestoreLeaderboardResult r = await leaderboardService.RestoreLeaderboard(id); + + return r.Match>( + board => Ok(LeaderboardViewModel.MapFrom(board)), + notFound => NotFound(), + neverDeleted => + NotFound(ProblemDetailsFactory.CreateProblemDetails(HttpContext, 404, "Not Deleted")), + conflict => Conflict(LeaderboardViewModel.MapFrom(conflict.Board)) + ); + } } diff --git a/LeaderboardBackend/Results.cs b/LeaderboardBackend/Results.cs index 9bd33669..cba5ddb7 100644 --- a/LeaderboardBackend/Results.cs +++ b/LeaderboardBackend/Results.cs @@ -1,3 +1,5 @@ +using LeaderboardBackend.Models.Entities; + namespace LeaderboardBackend.Result; public readonly record struct AccountConfirmed; @@ -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; diff --git a/LeaderboardBackend/Services/ILeaderboardService.cs b/LeaderboardBackend/Services/ILeaderboardService.cs index ae3aa72a..c6432365 100644 --- a/LeaderboardBackend/Services/ILeaderboardService.cs +++ b/LeaderboardBackend/Services/ILeaderboardService.cs @@ -11,7 +11,11 @@ public interface ILeaderboardService Task GetLeaderboardBySlug(string slug); Task> ListLeaderboards(); Task CreateLeaderboard(CreateLeaderboardRequest request); + Task RestoreLeaderboard(long id); } [GenerateOneOf] public partial class CreateLeaderboardResult : OneOfBase; + +[GenerateOneOf] +public partial class RestoreLeaderboardResult : OneOfBase; diff --git a/LeaderboardBackend/Services/Impl/LeaderboardService.cs b/LeaderboardBackend/Services/Impl/LeaderboardService.cs index 5efbf0b1..00da12fd 100644 --- a/LeaderboardBackend/Services/Impl/LeaderboardService.cs +++ b/LeaderboardBackend/Services/Impl/LeaderboardService.cs @@ -2,6 +2,7 @@ using LeaderboardBackend.Models.Requests; using LeaderboardBackend.Result; using Microsoft.EntityFrameworkCore; +using NodaTime; using Npgsql; namespace LeaderboardBackend.Services; @@ -42,4 +43,34 @@ public async Task CreateLeaderboard(CreateLeaderboardRe return lb; } + + public async Task 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; + } } diff --git a/LeaderboardBackend/openapi.json b/LeaderboardBackend/openapi.json index 665b934d..4e8fabb1 100644 --- a/LeaderboardBackend/openapi.json +++ b/LeaderboardBackend/openapi.json @@ -515,7 +515,7 @@ "tags": [ "Leaderboards" ], - "summary": "Gets a Leaderboard by its slug.", + "summary": "Gets a leaderboard by its slug.", "operationId": "getLeaderboardBySlug", "parameters": [ { @@ -664,6 +664,77 @@ } } }, + "/leaderboard/{id}/restore": { + "put": { + "tags": [ + "Leaderboards" + ], + "summary": "Restores a deleted leaderboard.", + "operationId": "restoreLeaderboard", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal Server Error" + }, + "200": { + "description": "The restored `Leaderboard`s view model.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LeaderboardViewModel" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "The requesting `User` is unauthorized to restore `Leaderboard`s." + }, + "404": { + "description": "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.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Another `Leaderboard` with the same slug has been created since, and therefore can't be restored. Will include the conflicting board in the response.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LeaderboardViewModel" + } + } + } + } + } + } + }, "/api/run/{id}": { "get": { "tags": [