diff --git a/LeaderboardBackend.Test/Leaderboards.cs b/LeaderboardBackend.Test/Leaderboards.cs index 17c7830b..44845d9a 100644 --- a/LeaderboardBackend.Test/Leaderboards.cs +++ b/LeaderboardBackend.Test/Leaderboards.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using System.Net; +using System.Net.Http; using System.Net.Http.Json; using System.Threading.Tasks; using FluentAssertions.Specialized; @@ -522,4 +523,137 @@ public async Task RestoreLeaderboard_Conflict() model.Should().NotBeNull(); model!.Id.Should().Be(reclaimed.Id); } + + [Test] + public async Task DeleteLeaderboard_Unauthenticated() + { + ApplicationContext context = _factory.Services.CreateScope().ServiceProvider.GetRequiredService(); + + Leaderboard lb = new() + { + Name = "The Witness", + Slug = "the-witness", + Info = "Time ends upon achieving enlightenment." + }; + + context.Add(lb); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + await FluentActions.Awaiting(() => _apiClient.Delete( + $"/leaderboard/{lb.Id}", + new() + )).Should().ThrowAsync().Where(e => e.Response.StatusCode == HttpStatusCode.Unauthorized); + + Leaderboard? found = await context.Leaderboards.FindAsync(lb.Id); + found.Should().NotBeNull(); + found!.DeletedAt.Should().BeNull(); + } + + [TestCase(UserRole.Banned)] + [TestCase(UserRole.Confirmed)] + [TestCase(UserRole.Registered)] + public async Task DeleteLeaderboard_BadRole(UserRole role) + { + IUserService userService = _factory.Services.CreateScope().ServiceProvider.GetRequiredService(); + ApplicationContext context = _factory.Services.CreateScope().ServiceProvider.GetRequiredService(); + + string email = $"testuser.deletelb.{role}@example.com"; + + RegisterRequest registerRequest = new() + { + Email = email, + Password = "Passw0rd", + Username = $"DeleteLBTest{role}" + }; + + Leaderboard lb = new() + { + Name = "LB Delete Bad Role Test Board", + Slug = $"lb-delete-bad-role-test-{role}", + }; + + await userService.CreateUser(registerRequest); + context.Leaderboards.Add(lb); + await context.SaveChangesAsync(); + LoginResponse res = await _apiClient.LoginUser(registerRequest.Email, registerRequest.Password); + User? user = await userService.GetUserByEmail(email); + user.Should().NotBeNull(); + user!.Role = role; + context.Users.Update(user); + await context.SaveChangesAsync(); + + await FluentActions.Awaiting(() => _apiClient.Delete( + $"/leaderboard/{lb.Id}", + new() { Jwt = res.Token } + )).Should().ThrowAsync().Where(e => e.Response.StatusCode == HttpStatusCode.Forbidden); + + context.ChangeTracker.Clear(); + Leaderboard? found = await context.Leaderboards.FindAsync(lb.Id); + found.Should().NotBeNull(); + found!.DeletedAt.Should().BeNull(); + } + + [TestCase(long.MaxValue)] + [TestCase("sansundertale")] + public async Task DeleteLeaderboard_NotFound(object id) => + await FluentActions.Awaiting(() => _apiClient.Delete( + $"/leaderboard/{id}", + new() { Jwt = _jwt } + )).Should().ThrowAsync().Where(e => e.Response.StatusCode == HttpStatusCode.NotFound); + + [Test] + public async Task DeleteLeaderboard_AlreadyDeleted() + { + ApplicationContext context = _factory.Services.CreateScope().ServiceProvider.GetRequiredService(); + Instant now = _clock.GetCurrentInstant(); + + Leaderboard lb = new() + { + Name = "The Elder Scrolls V: Skyrim", + Slug = "tesv-skyrim", + UpdatedAt = now, + DeletedAt = now + }; + + context.Leaderboards.Add(lb); + await context.SaveChangesAsync(); + + ExceptionAssertions ex = await FluentActions.Awaiting(() => _apiClient.Delete( + $"/leaderboard/{lb.Id}", + new() { Jwt = _jwt } + )).Should().ThrowAsync().Where(e => e.Response.StatusCode == HttpStatusCode.NotFound); + + ProblemDetails? problemDetails = await ex.Which.Response.Content.ReadFromJsonAsync( + TestInitCommonFields.JsonSerializerOptions + ); + + problemDetails.Should().NotBeNull(); + problemDetails!.Title.Should().Be("Already Deleted"); + } + + [Test] + public async Task DeleteLeaderboard_Success() + { + ApplicationContext context = _factory.Services.CreateScope().ServiceProvider.GetRequiredService(); + + Leaderboard lb = new() + { + Name = "Minecraft", + Slug = "minecraft" + }; + + context.Add(lb); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + _clock.AdvanceMinutes(1); + HttpResponseMessage res = await _apiClient.Delete($"/leaderboard/{lb.Id}", new() { Jwt = _jwt }); + res.Should().HaveStatusCode(HttpStatusCode.NoContent); + Leaderboard? found = await context.Leaderboards.FindAsync(lb.Id); + found.Should().NotBeNull(); + found!.DeletedAt.Should().NotBeNull(); + found!.DeletedAt!.Value.Should().Be(_clock.GetCurrentInstant()); + found!.UpdatedAt.Should().NotBeNull(); + found!.UpdatedAt!.Value.Should().Be(_clock.GetCurrentInstant()); + } } diff --git a/LeaderboardBackend/Controllers/LeaderboardsController.cs b/LeaderboardBackend/Controllers/LeaderboardsController.cs index 039871c5..f792f509 100644 --- a/LeaderboardBackend/Controllers/LeaderboardsController.cs +++ b/LeaderboardBackend/Controllers/LeaderboardsController.cs @@ -3,6 +3,7 @@ using LeaderboardBackend.Models.Requests; using LeaderboardBackend.Models.Validation; using LeaderboardBackend.Models.ViewModels; +using LeaderboardBackend.Result; using LeaderboardBackend.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -109,4 +110,29 @@ long id conflict => Conflict(LeaderboardViewModel.MapFrom(conflict.Board)) ); } + + [Authorize(Policy = UserTypes.ADMINISTRATOR)] + [HttpDelete("leaderboard/{id:long}")] + [SwaggerOperation("Deletes a leaderboard. This request is restricted to Administrators.", OperationId = "deleteLeaderboard")] + [SwaggerResponse(204)] + [SwaggerResponse(401)] + [SwaggerResponse(403)] + [SwaggerResponse( + 404, + """ + The leaderboard does not exist (Not Found) or was already deleted (Already Deleted). + Use the title field of the response to differentiate between the two cases if necessary. + """, + typeof(ProblemDetails) + )] + public async Task DeleteLeaderboard([FromRoute, SwaggerParameter(Required = true)] long id) + { + DeleteResult res = await leaderboardService.DeleteLeaderboard(id); + + return res.Match( + success => NoContent(), + notFound => NotFound(), + alreadyDeleted => NotFound(ProblemDetailsFactory.CreateProblemDetails(HttpContext, 404, "Already Deleted")) + ); + } } diff --git a/LeaderboardBackend/Results.cs b/LeaderboardBackend/Results.cs index cba5ddb7..d39e6cdf 100644 --- a/LeaderboardBackend/Results.cs +++ b/LeaderboardBackend/Results.cs @@ -1,8 +1,11 @@ using LeaderboardBackend.Models.Entities; +using OneOf; +using OneOf.Types; namespace LeaderboardBackend.Result; public readonly record struct AccountConfirmed; +public readonly record struct AlreadyDeleted; public readonly record struct AlreadyUsed; public readonly record struct BadCredentials; public readonly record struct BadRole; @@ -15,3 +18,6 @@ namespace LeaderboardBackend.Result; public readonly record struct RestoreLeaderboardConflict(Leaderboard Board); public readonly record struct UserNotFound; public readonly record struct UserBanned; + +[GenerateOneOf] +public partial class DeleteResult : OneOfBase; diff --git a/LeaderboardBackend/Services/ILeaderboardService.cs b/LeaderboardBackend/Services/ILeaderboardService.cs index c6432365..2c3371d3 100644 --- a/LeaderboardBackend/Services/ILeaderboardService.cs +++ b/LeaderboardBackend/Services/ILeaderboardService.cs @@ -12,6 +12,7 @@ public interface ILeaderboardService Task> ListLeaderboards(); Task CreateLeaderboard(CreateLeaderboardRequest request); Task RestoreLeaderboard(long id); + Task DeleteLeaderboard(long id); } [GenerateOneOf] diff --git a/LeaderboardBackend/Services/Impl/LeaderboardService.cs b/LeaderboardBackend/Services/Impl/LeaderboardService.cs index 00da12fd..35bdc1c8 100644 --- a/LeaderboardBackend/Services/Impl/LeaderboardService.cs +++ b/LeaderboardBackend/Services/Impl/LeaderboardService.cs @@ -4,10 +4,11 @@ using Microsoft.EntityFrameworkCore; using NodaTime; using Npgsql; +using OneOf.Types; namespace LeaderboardBackend.Services; -public class LeaderboardService(ApplicationContext applicationContext) : ILeaderboardService +public class LeaderboardService(ApplicationContext applicationContext, IClock clock) : ILeaderboardService { public async Task GetLeaderboard(long id) => await applicationContext.Leaderboards.FindAsync(id); @@ -73,4 +74,23 @@ public async Task RestoreLeaderboard(long id) return lb; } + + public async Task DeleteLeaderboard(long id) + { + Leaderboard? lb = await applicationContext.Leaderboards.FindAsync(id); + + if (lb is null) + { + return new NotFound(); + } + + if (lb.DeletedAt is not null) + { + return new AlreadyDeleted(); + } + + lb.DeletedAt = clock.GetCurrentInstant(); + await applicationContext.SaveChangesAsync(); + return new Success(); + } } diff --git a/LeaderboardBackend/openapi.json b/LeaderboardBackend/openapi.json index 4e8fabb1..c6434a82 100644 --- a/LeaderboardBackend/openapi.json +++ b/LeaderboardBackend/openapi.json @@ -735,6 +735,60 @@ } } }, + "/leaderboard/{id}": { + "delete": { + "tags": [ + "Leaderboards" + ], + "summary": "Deletes a leaderboard. This request is restricted to Administrators.", + "operationId": "deleteLeaderboard", + "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" + }, + "204": { + "description": "No Content" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "The leaderboard does not exist (Not Found) or was already deleted (Already Deleted).\nUse the title field of the response to differentiate between the two cases if necessary.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/api/run/{id}": { "get": { "tags": [