Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Restore Categories #275

Merged
merged 6 commits into from
Jan 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
267 changes: 266 additions & 1 deletion LeaderboardBackend.Test/Categories.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ public async Task OneTimeSetUp()
public async Task GetCategory_NotFound() =>
await _apiClient.Awaiting(
a => a.Get<CategoryViewModel>(
$"/api/cateogries/69",
$"/api/category/69",
new() { Jwt = _jwt }
)
).Should()
Expand Down Expand Up @@ -451,4 +451,269 @@ public async Task DeleteCategory_NotFound_AlreadyDeleted()
Category? retrieved = await context.FindAsync<Category>(cat.Id);
retrieved!.UpdatedAt.Should().BeNull();
}

[Test]
public async Task RestoreCategory_OK()
{
IServiceScope scope = _factory.Services.CreateScope();
ApplicationContext context = scope.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();

CategoryViewModel restored = await _apiClient.Put<CategoryViewModel>(
$"category/{cat.Id}/restore",
new()
{
Jwt = _jwt
}
);

restored.DeletedAt.Should().BeNull();

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

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

Category cat = new()
{
Name = "Restore Cat UnauthN",
Slug = "restorecat-unauthn",
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();

await FluentActions.Awaiting(() => _apiClient.Put<CategoryViewModel>(
"category/1/restore",
new() { }
)).Should().ThrowAsync<RequestFailureException>().Where(e => e.Response.StatusCode == HttpStatusCode.Unauthorized);

Category? verify = await context.FindAsync<Category>(cat.Id);
verify!.DeletedAt.Should().Be(_clock.GetCurrentInstant());
}

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

Category cat = new()
{
Name = "Restore Cat UnauthZ",
Slug = $"restorecat-unauthz-{role}",
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();

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

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

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

await FluentActions.Awaiting(() => _apiClient.Put<CategoryViewModel>(
$"category/{cat.Id}/restore",
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().Be(_clock.GetCurrentInstant());
}

[Test]
public async Task RestoreCategory_NotFound()
{
ExceptionAssertions<RequestFailureException> exAssert = await FluentActions.Awaiting(() => _apiClient.Put<CategoryViewModel>(
$"category/{int.MaxValue}/restore",
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 RestoreCategory_NotFound_WasNeverDeleted()
{
IServiceScope scope = _factory.Services.CreateScope();
ApplicationContext context = scope.ServiceProvider.GetRequiredService<ApplicationContext>();

Category cat = new()
{
Name = "Restore Cat Not Found Never Deleted",
Slug = "restorecat-notfound-never-deleted",
LeaderboardId = _createdLeaderboard.Id,
SortDirection = SortDirection.Ascending,
Type = RunType.Score,
};

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

ExceptionAssertions<RequestFailureException> exAssert = await FluentActions.Awaiting(() => _apiClient.Put<CategoryViewModel>(
$"category/{cat.Id}/restore",
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 Deleted");
}

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

Category deleted = new()
{
Name = "Restore Cat To Conflict",
Slug = "restorecat-to-conflict",
LeaderboardId = _createdLeaderboard.Id,
SortDirection = SortDirection.Ascending,
Type = RunType.Score,
DeletedAt = _clock.GetCurrentInstant(),
};

Category conflicting = new()
{
Name = "Restore Cat Conflicting",
Slug = "restorecat-to-conflict",
LeaderboardId = _createdLeaderboard.Id,
SortDirection = SortDirection.Ascending,
Type = RunType.Score,
};

context.Categories.AddRange(deleted, conflicting);
await context.SaveChangesAsync();
deleted.Id.Should().NotBe(default);
conflicting.Id.Should().NotBe(default);
context.ChangeTracker.Clear();

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

ConflictDetails<CategoryViewModel>? problemDetails = await exAssert.Which.Response.Content.ReadFromJsonAsync<ConflictDetails<CategoryViewModel>>(TestInitCommonFields.JsonSerializerOptions);
problemDetails!.Title.Should().Be("Conflict");
problemDetails!.Conflicting.Should().BeEquivalentTo(CategoryViewModel.MapFrom(conflicting));

Category? verify = await context.FindAsync<Category>(deleted.Id);
verify!.DeletedAt.Should().Be(_clock.GetCurrentInstant());
verify!.UpdatedAt.Should().BeNull();
}

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

Category deleted = new()
{
Name = "Restore Cat Should Not Conflict",
Slug = "restorecat-should-not-conflict",
LeaderboardId = _createdLeaderboard.Id,
SortDirection = SortDirection.Ascending,
Type = RunType.Score,
DeletedAt = _clock.GetCurrentInstant(),
};

Leaderboard board = new()
{
Name = "Restore Cat Board",
Slug = "restorecat-board",
};

context.AddRange(deleted, board);
await context.SaveChangesAsync();
deleted.Id.Should().NotBe(default);
deleted.Id.Should().NotBe(_createdLeaderboard.Id);
board.Id.Should().NotBe(default);

Category notConflicting = new()
{
Name = "Restore Cat Conflicting",
Slug = deleted.Slug,
LeaderboardId = board.Id,
SortDirection = SortDirection.Ascending,
Type = RunType.Score,
DeletedAt = _clock.GetCurrentInstant(),
};

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

CategoryViewModel restored = await _apiClient.Put<CategoryViewModel>(
$"category/{notConflicting.Id}/restore",
new()
{
Jwt = _jwt
}
);

restored.DeletedAt.Should().BeNull();

Category? verify = await context.FindAsync<Category>(notConflicting.Id);
verify!.DeletedAt.Should().BeNull();
}
}
28 changes: 28 additions & 0 deletions LeaderboardBackend/Controllers/CategoriesController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,32 @@ public async Task<ActionResult> DeleteCategory([FromRoute] long id)
alreadyDeleted => NotFound(ProblemDetailsFactory.CreateProblemDetails(HttpContext, 404, "Already Deleted"))
);
}

[Authorize(Policy = UserTypes.ADMINISTRATOR)]
[HttpPut("category/{id:long}/restore")]
[SwaggerOperation("Restores a deleted Category.", OperationId = "restoreCategory")]
[SwaggerResponse(200, "The restored `Category`s view model.", typeof(CategoryViewModel))]
[SwaggerResponse(401)]
[SwaggerResponse(403, "The requesting `User` is unauthorized to restore `Category`s.")]
[SwaggerResponse(404, "The `Category` 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 `Category` with the same slug has been created since, and therefore can't be restored. Said `Category` will be returned in the `conflicting` field in the response.", typeof(ConflictDetails<CategoryViewModel>))]
public async Task<ActionResult<CategoryViewModel>> RestoreCategory(
long id
)
{
RestoreResult<Category> r = await categoryService.RestoreCategory(id);

return r.Match<ActionResult<CategoryViewModel>>(
category => Ok(CategoryViewModel.MapFrom(category)),
notFound => NotFound(),
neverDeleted =>
NotFound(ProblemDetailsFactory.CreateProblemDetails(HttpContext, 404, "Not Deleted")),
conflict =>
{
ProblemDetails problemDetails = ProblemDetailsFactory.CreateProblemDetails(HttpContext, StatusCodes.Status409Conflict);
problemDetails.Extensions.Add("conflicting", CategoryViewModel.MapFrom(conflict.Conflicting));
return Conflict(problemDetails);
}
);
}
}
4 changes: 2 additions & 2 deletions LeaderboardBackend/Controllers/LeaderboardsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ public async Task<ActionResult<LeaderboardViewModel>> RestoreLeaderboard(
long id
)
{
RestoreLeaderboardResult r = await leaderboardService.RestoreLeaderboard(id);
RestoreResult<Leaderboard> r = await leaderboardService.RestoreLeaderboard(id);

return r.Match<ActionResult<LeaderboardViewModel>>(
board => Ok(LeaderboardViewModel.MapFrom(board)),
Expand All @@ -109,7 +109,7 @@ long id
NotFound(ProblemDetailsFactory.CreateProblemDetails(HttpContext, 404, "Not Deleted")),
conflict =>
{
ProblemDetails problemDetails = ProblemDetailsFactory.CreateProblemDetails(HttpContext, StatusCodes.Status409Conflict, "Conflict");
ProblemDetails problemDetails = ProblemDetailsFactory.CreateProblemDetails(HttpContext, StatusCodes.Status409Conflict);
problemDetails.Extensions.Add("conflicting", LeaderboardViewModel.MapFrom(conflict.Conflicting));
return Conflict(problemDetails);
}
Expand Down
5 changes: 4 additions & 1 deletion LeaderboardBackend/Results.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ namespace LeaderboardBackend.Result;
public record Conflict<T>(T Conflicting);
public readonly record struct EmailFailed;
public readonly record struct Expired;
public readonly record struct LeaderboardNeverDeleted;
public readonly record struct NeverDeleted;
public readonly record struct UserNotFound;
public readonly record struct UserBanned;

Expand All @@ -21,3 +21,6 @@ public partial class DeleteResult : OneOfBase<Success, NotFound, AlreadyDeleted>

[GenerateOneOf]
public partial class UpdateResult<T> : OneOfBase<Conflict<T>, NotFound, Success>;

[GenerateOneOf]
public partial class RestoreResult<T> : OneOfBase<T, NotFound, NeverDeleted, Conflict<T>>;
1 change: 1 addition & 0 deletions LeaderboardBackend/Services/ICategoryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public interface ICategoryService
Task<CreateCategoryResult> CreateCategory(long leaderboardId, CreateCategoryRequest request);
Task<Category?> GetCategoryForRun(Run run);
Task<DeleteResult> DeleteCategory(long id);
Task<RestoreResult<Category>> RestoreCategory(long id);
}

[GenerateOneOf]
Expand Down
5 changes: 1 addition & 4 deletions LeaderboardBackend/Services/ILeaderboardService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,10 @@ public interface ILeaderboardService
Task<Leaderboard?> GetLeaderboardBySlug(string slug);
Task<List<Leaderboard>> ListLeaderboards(bool includeDeleted);
Task<CreateLeaderboardResult> CreateLeaderboard(CreateLeaderboardRequest request);
Task<RestoreLeaderboardResult> RestoreLeaderboard(long id);
Task<RestoreResult<Leaderboard>> RestoreLeaderboard(long id);
Task<DeleteResult> DeleteLeaderboard(long id);
Task<UpdateResult<Leaderboard>> UpdateLeaderboard(long id, UpdateLeaderboardRequest request);
}

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

[GenerateOneOf]
public partial class RestoreLeaderboardResult : OneOfBase<Leaderboard, NotFound, LeaderboardNeverDeleted, Conflict<Leaderboard>>;
Loading
Loading