Skip to content

Commit

Permalink
Delete Categories (#274)
Browse files Browse the repository at this point in the history
* Add Delete Category endpoint

* Address changes:
- Fix DeleteCategory Swagger operationId
- Replace endpoint calls with direct DB additions in test scaffolding

* Remove unnecessary static keyword in Category tests

* Enhance tests
  • Loading branch information
zysim authored Jan 26, 2025
1 parent 66b42eb commit eeef7bf
Show file tree
Hide file tree
Showing 5 changed files with 274 additions and 10 deletions.
181 changes: 172 additions & 9 deletions LeaderboardBackend.Test/Categories.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading.Tasks;
using FluentAssertions.Specialized;
using LeaderboardBackend.Models;
Expand All @@ -11,6 +11,7 @@
using LeaderboardBackend.Test.Lib;
using LeaderboardBackend.Test.TestApi;
using LeaderboardBackend.Test.TestApi.Extensions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
Expand Down Expand Up @@ -61,7 +62,7 @@ public async Task OneTimeSetUp()
public void OneTimeTearDown() => _factory.Dispose();

[Test]
public static async Task GetCategory_NotFound() =>
public async Task GetCategory_NotFound() =>
await _apiClient.Awaiting(
a => a.Get<CategoryViewModel>(
$"/api/cateogries/69",
Expand All @@ -72,7 +73,7 @@ await _apiClient.Awaiting(
.Where(e => e.Response.StatusCode == HttpStatusCode.NotFound);

[Test]
public static async Task CreateCategory_GetCategory_OK()
public async Task CreateCategory_GetCategory_OK()
{
CreateCategoryRequest request = new()
{
Expand Down Expand Up @@ -102,7 +103,7 @@ public static async Task CreateCategory_GetCategory_OK()
}

[Test]
public static async Task CreateCategory_Unauthenticated()
public async Task CreateCategory_Unauthenticated()
{
CreateCategoryRequest request = new()
{
Expand All @@ -125,7 +126,7 @@ await FluentActions.Awaiting(() => _apiClient.Post<CategoryViewModel>(
[TestCase(UserRole.Banned)]
[TestCase(UserRole.Confirmed)]
[TestCase(UserRole.Registered)]
public static async Task CreateCategory_BadRole(UserRole role)
public async Task CreateCategory_BadRole(UserRole role)
{
IServiceScope scope = _factory.Services.CreateScope();
IUserService userService = scope.ServiceProvider.GetRequiredService<IUserService>();
Expand Down Expand Up @@ -163,7 +164,7 @@ await FluentActions.Awaiting(() => _apiClient.Post<CategoryViewModel>(
}

[Test]
public static async Task CreateCategory_LeaderboardNotFound()
public async Task CreateCategory_LeaderboardNotFound()
{
CreateCategoryRequest request = new()
{
Expand All @@ -189,7 +190,7 @@ public static async Task CreateCategory_LeaderboardNotFound()
}

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

Expand Down Expand Up @@ -227,7 +228,7 @@ await FluentActions.Awaiting(() => _apiClient.Post<CategoryViewModel>(
}

[Test]
public static async Task CreateCategory_Conflict()
public async Task CreateCategory_Conflict()
{
CreateCategoryRequest request = new()
{
Expand Down Expand Up @@ -265,7 +266,7 @@ public static async Task CreateCategory_Conflict()
[TestCase("Bad Data", null, SortDirection.Ascending, RunType.Score, HttpStatusCode.UnprocessableContent)]
[TestCase("Bad Request Invalid SortDirection", "invalid-sort-direction", "Invalid SortDirection", RunType.Score, HttpStatusCode.BadRequest)]
[TestCase("Bad Request Invalid Type", "invalid-type", SortDirection.Ascending, "Invalid Type", HttpStatusCode.BadRequest)]
public static async Task CreateCategory_BadData(string? name, string? slug, object sortDirection, object runType, HttpStatusCode expectedCode)
public async Task CreateCategory_BadData(string? name, string? slug, object sortDirection, object runType, HttpStatusCode expectedCode)
{
var request = new
{
Expand All @@ -288,4 +289,166 @@ public static async Task CreateCategory_BadData(string? name, string? slug, obje
problemDetails.Should().NotBeNull();
problemDetails!.Title.Should().Be("One or more validation errors occurred.");
}

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

Category cat = new()
{
Name = "Delete Cat OK",
Slug = "deletecat-ok",
LeaderboardId = _createdLeaderboard.Id,
SortDirection = SortDirection.Ascending,
Type = RunType.Time
};

context.Add(cat);
await context.SaveChangesAsync();
cat.Id.Should().NotBe(default);
context.ChangeTracker.Clear();

HttpResponseMessage response = await _apiClient.Delete(
$"/category/{cat.Id}",
new()
{
Jwt = _jwt
}
);
response.StatusCode.Should().Be(HttpStatusCode.NoContent);

Category? deleted = await context.FindAsync<Category>(cat.Id);

deleted.Should().NotBeNull();
deleted!.UpdatedAt.Should().Be(_clock.GetCurrentInstant());
deleted!.DeletedAt.Should().Be(_clock.GetCurrentInstant());
}

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

Category cat = new()
{
Name = "Delete Cat UnauthN",
Slug = "deletecat-unauthn",
LeaderboardId = _createdLeaderboard.Id,
SortDirection = SortDirection.Ascending,
Type = RunType.Score,
};

context.Add(cat);
await context.SaveChangesAsync();
cat.Id.Should().NotBe(default);
context.ChangeTracker.Clear();

await FluentActions.Awaiting(() => _apiClient.Delete(
$"category/{cat.Id}",
new() { }
)).Should().ThrowAsync<RequestFailureException>().Where(e => e.Response.StatusCode == HttpStatusCode.Unauthorized);

Category? retrieved = await context.FindAsync<Category>(cat.Id);
retrieved!.DeletedAt.Should().BeNull();
}

[TestCase(UserRole.Banned)]
[TestCase(UserRole.Confirmed)]
[TestCase(UserRole.Registered)]
public async Task DeleteCategory_BadRole(UserRole role)
{
IServiceScope scope = _factory.Services.CreateScope();
IUserService userService = scope.ServiceProvider.GetRequiredService<IUserService>();
ApplicationContext context = scope.ServiceProvider.GetRequiredService<ApplicationContext>();

string email = $"testuser.deletecat.{role}@example.com";

RegisterRequest registerRequest = new()
{
Email = email,
Password = "Passw0rd",
Username = $"DeleteCatTest{role}"
};

await userService.CreateUser(registerRequest);
LoginResponse res = await _apiClient.LoginUser(registerRequest.Email, registerRequest.Password);

Category cat = new()
{
Name = "Bad Role",
Slug = $"deletecat-bad-role-{role}",
LeaderboardId = _createdLeaderboard.Id,
SortDirection = SortDirection.Ascending,
Type = RunType.Time,
};

context.Add(cat);
await context.SaveChangesAsync();
cat.Id.Should().NotBe(default);
context.ChangeTracker.Clear();

await FluentActions.Awaiting(() => _apiClient.Delete(
$"/category/{cat.Id}",
new()
{
Jwt = res.Token
}
)).Should().ThrowAsync<RequestFailureException>().Where(e => e.Response.StatusCode == HttpStatusCode.Forbidden);

Category? retrieved = await context.FindAsync<Category>(cat.Id);
retrieved!.DeletedAt.Should().BeNull();
}

[Test]
public async Task DeleteCategory_NotFound()
{
ExceptionAssertions<RequestFailureException> exAssert = await FluentActions.Awaiting(() => _apiClient.Delete(
$"/category/{int.MaxValue}",
new()
{
Jwt = _jwt,
}
)).Should().ThrowAsync<RequestFailureException>().Where(e => e.Response.StatusCode == HttpStatusCode.NotFound);

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

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

Category cat = new()
{
Name = "Deleted",
Slug = "deletedcat-already-deleted",
LeaderboardId = _createdLeaderboard.Id,
SortDirection = SortDirection.Ascending,
Type = RunType.Score,
DeletedAt = _clock.GetCurrentInstant(),
};

context.Categories.Add(cat);
await context.SaveChangesAsync();
cat.Id.Should().NotBe(default);
context.ChangeTracker.Clear();

ExceptionAssertions<RequestFailureException> exAssert = await FluentActions.Awaiting(() => _apiClient.Delete(
$"/category/{cat.Id}",
new()
{
Jwt = _jwt,
}
)).Should().ThrowAsync<RequestFailureException>().Where(e => e.Response.StatusCode == HttpStatusCode.NotFound);

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

Category? retrieved = await context.FindAsync<Category>(cat.Id);
retrieved!.UpdatedAt.Should().BeNull();
}
}
26 changes: 26 additions & 0 deletions LeaderboardBackend/Controllers/CategoriesController.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 @@ -60,4 +61,29 @@ public async Task<ActionResult<CategoryViewModel>> CreateCategory(
notFound => NotFound(ProblemDetailsFactory.CreateProblemDetails(HttpContext, 404, "Leaderboard Not Found"))
);
}

[Authorize(Policy = UserTypes.ADMINISTRATOR)]
[HttpDelete("category/{id:long}")]
[SwaggerOperation("Deletes a Category. This request is restricted to Administrators.", OperationId = "deleteCategory")]
[SwaggerResponse(204)]
[SwaggerResponse(401)]
[SwaggerResponse(403)]
[SwaggerResponse(
404,
"""
The Category 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> DeleteCategory([FromRoute] long id)
{
DeleteResult res = await categoryService.DeleteCategory(id);

return res.Match<ActionResult>(
success => NoContent(),
notFound => NotFound(),
alreadyDeleted => NotFound(ProblemDetailsFactory.CreateProblemDetails(HttpContext, 404, "Already Deleted"))
);
}
}
1 change: 1 addition & 0 deletions LeaderboardBackend/Services/ICategoryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public interface ICategoryService
Task<Category?> GetCategory(long id);
Task<CreateCategoryResult> CreateCategory(long leaderboardId, CreateCategoryRequest request);
Task<Category?> GetCategoryForRun(Run run);
Task<DeleteResult> DeleteCategory(long id);
}

[GenerateOneOf]
Expand Down
22 changes: 21 additions & 1 deletion LeaderboardBackend/Services/Impl/CategoryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@
using LeaderboardBackend.Models.Requests;
using LeaderboardBackend.Result;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using Npgsql;
using OneOf.Types;

namespace LeaderboardBackend.Services;

public class CategoryService(ApplicationContext applicationContext) : ICategoryService
public class CategoryService(ApplicationContext applicationContext, IClock clock) : ICategoryService
{
public async Task<Category?> GetCategory(long id) =>
await applicationContext.Categories.FindAsync(id);
Expand Down Expand Up @@ -46,4 +47,23 @@ public async Task<CreateCategoryResult> CreateCategory(long leaderboardId, Creat

public async Task<Category?> GetCategoryForRun(Run run) =>
await applicationContext.Categories.FindAsync(run.CategoryId);

public async Task<DeleteResult> DeleteCategory(long id)
{
Category? category = await applicationContext.Categories.FindAsync(id);

if (category == null)
{
return new NotFound();
}

if (category.DeletedAt != null)
{
return new AlreadyDeleted();
}

category.DeletedAt = clock.GetCurrentInstant();
await applicationContext.SaveChangesAsync();
return new Success();
}
}
Loading

0 comments on commit eeef7bf

Please sign in to comment.