Skip to content

Commit

Permalink
Delete Leaderboard Endpoint (#253)
Browse files Browse the repository at this point in the history
* Add LB deletion tests.

* Use the correct endpoint for testing.

* Use a unique LB for each test.

* Don't explicitly check for no error upon login.

* Add DeleteResult.

* Implement LeaderboardService.DeleteLeaderboard.

* Implement LB deletion endpoint.

* Formatting.

* Update openapi.json.

* Set UpdatedAt, not just DeletedAt.

* formatting

* Set the user's role in relevant tests.

* Assert the title of the problem details.

* Add a non-int64 test case.
  • Loading branch information
TheTedder authored Oct 28, 2024
1 parent ac3f493 commit 08f8b06
Show file tree
Hide file tree
Showing 6 changed files with 242 additions and 1 deletion.
134 changes: 134 additions & 0 deletions LeaderboardBackend.Test/Leaderboards.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<ApplicationContext>();

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<RequestFailureException>().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<IUserService>();
ApplicationContext context = _factory.Services.CreateScope().ServiceProvider.GetRequiredService<ApplicationContext>();

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<RequestFailureException>().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<RequestFailureException>().Where(e => e.Response.StatusCode == HttpStatusCode.NotFound);

[Test]
public async Task DeleteLeaderboard_AlreadyDeleted()
{
ApplicationContext context = _factory.Services.CreateScope().ServiceProvider.GetRequiredService<ApplicationContext>();
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<RequestFailureException> ex = await FluentActions.Awaiting(() => _apiClient.Delete(
$"/leaderboard/{lb.Id}",
new() { Jwt = _jwt }
)).Should().ThrowAsync<RequestFailureException>().Where(e => e.Response.StatusCode == HttpStatusCode.NotFound);

ProblemDetails? problemDetails = await ex.Which.Response.Content.ReadFromJsonAsync<ProblemDetails>(
TestInitCommonFields.JsonSerializerOptions
);

problemDetails.Should().NotBeNull();
problemDetails!.Title.Should().Be("Already Deleted");
}

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

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());
}
}
26 changes: 26 additions & 0 deletions LeaderboardBackend/Controllers/LeaderboardsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<ActionResult> DeleteLeaderboard([FromRoute, SwaggerParameter(Required = true)] long id)
{
DeleteResult res = await leaderboardService.DeleteLeaderboard(id);

return res.Match<ActionResult>(
success => NoContent(),
notFound => NotFound(),
alreadyDeleted => NotFound(ProblemDetailsFactory.CreateProblemDetails(HttpContext, 404, "Already Deleted"))
);
}
}
6 changes: 6 additions & 0 deletions LeaderboardBackend/Results.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<Success, NotFound, AlreadyDeleted>;
1 change: 1 addition & 0 deletions LeaderboardBackend/Services/ILeaderboardService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public interface ILeaderboardService
Task<List<Leaderboard>> ListLeaderboards();
Task<CreateLeaderboardResult> CreateLeaderboard(CreateLeaderboardRequest request);
Task<RestoreLeaderboardResult> RestoreLeaderboard(long id);
Task<DeleteResult> DeleteLeaderboard(long id);
}

[GenerateOneOf]
Expand Down
22 changes: 21 additions & 1 deletion LeaderboardBackend/Services/Impl/LeaderboardService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Leaderboard?> GetLeaderboard(long id) =>
await applicationContext.Leaderboards.FindAsync(id);
Expand Down Expand Up @@ -73,4 +74,23 @@ public async Task<RestoreLeaderboardResult> RestoreLeaderboard(long id)

return lb;
}

public async Task<DeleteResult> 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();
}
}
54 changes: 54 additions & 0 deletions LeaderboardBackend/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down

0 comments on commit 08f8b06

Please sign in to comment.