Skip to content

Commit

Permalink
Update Leaderboard Endpoint (leaderboardsgg#256)
Browse files Browse the repository at this point in the history
* Add Patch method to TestApiClient.

* Make LB Info non-nullable.

* Don't explicitly set null Info.

* Separate NotEmpty from Slug constraint.

* Add UpdateLeaderboardRequest.

* Fix Leaderboard Name docstring.

* Make LB test class public.

* Add update leaderboard tests.

* Add test for updating only one field.

* Use reusable results.

* Add UpdateLeaderboard service method.

* Remove duplicate result.

* Let deleted boards be updated.

* Fix boolean logic error in validation.

* Add a test case for blank names.

* Add more LB update request validation.

* Add update LB endpoint.

* Update openapi.json.

* formatting

* Don't mark UpdateLeaderboard fields nullable.

* Document missing 422 response.
  • Loading branch information
TheTedder authored Nov 5, 2024
1 parent a569c2f commit 36e114a
Show file tree
Hide file tree
Showing 17 changed files with 966 additions and 23 deletions.
1 change: 0 additions & 1 deletion LeaderboardBackend.Test/Categories.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@ public static async Task CreateCategory_GetCategory_OK()
{
Name = "Super Mario Bros.",
Slug = "super_mario_bros",
Info = null
},
Jwt = _jwt
}
Expand Down
314 changes: 313 additions & 1 deletion LeaderboardBackend.Test/Leaderboards.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
namespace LeaderboardBackend.Test;

[TestFixture]
internal class Leaderboards
public class Leaderboards
{
private static TestApiClient _apiClient = null!;
private static WebApplicationFactory<Program> _factory = null!;
Expand Down Expand Up @@ -663,4 +663,316 @@ public async Task DeleteLeaderboard_Success()
found!.UpdatedAt.Should().NotBeNull();
found!.UpdatedAt!.Value.Should().Be(_clock.GetCurrentInstant());
}

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

Leaderboard lb = new()
{
Name = "Celeste",
Slug = "celest",
};

context.Add(lb);
await context.SaveChangesAsync();
context.ChangeTracker.Clear();
_clock.AdvanceMinutes(1);

await FluentActions.Awaiting(() => _apiClient.Patch(
$"/leaderboard/{lb.Id}", new()
{
Body = new UpdateLeaderboardRequest()
{
Slug = "celeste"
}
}
)).Should().ThrowAsync<RequestFailureException>().Where(e => e.Response.StatusCode == HttpStatusCode.Unauthorized);

Leaderboard? found = await context.Leaderboards.FindAsync(lb.Id);
found.Should().BeEquivalentTo(lb, config => config.Excluding(l => l.Categories));
}

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

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

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

Leaderboard lb = new()
{
Name = "LB Update Bad Role Test Board",
Slug = $"lb-update-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();
_clock.AdvanceMinutes(1);

await FluentActions.Awaiting(() => _apiClient.Patch(
$"/leaderboard/{lb.Id}",
new()
{
Body = new UpdateLeaderboardRequest()
{
Slug = "amogus"
},
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.Should().BeEquivalentTo(lb, config => config.Excluding(l => l.Categories));
}

[TestCase(long.MaxValue)]
[TestCase("partyrockersinthehousetonight")]
public async Task UpdateLeaderboard_NotFound(object id) =>
await FluentActions.Awaiting(() => _apiClient.Patch(
$"/leaderboard/{id}",
new()
{
Body = new UpdateLeaderboardRequest()
{
Slug = "fnaf",
Info = "Actually, it's \"party rock is in the house tonight.\""
},
Jwt = _jwt
}
)).Should().ThrowAsync<RequestFailureException>().Where(e => e.Response.StatusCode == HttpStatusCode.NotFound);

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

Leaderboard lb = new()
{
Name = "Hotel Mario",
Slug = "hotel-mario"
};

context.Leaderboards.Add(lb);
await context.SaveChangesAsync();

await FluentActions.Awaiting(() => _apiClient.Patch(
$"/leaderboard/{lb.Id}",
new()
{
Body = new UpdateLeaderboardRequest(),
Jwt = _jwt
}
)).Should().ThrowAsync<RequestFailureException>().Where(e => e.Response.StatusCode == HttpStatusCode.UnprocessableContent);
}

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

Leaderboard lb = new()
{
Name = "Prey (2006)",
Slug = "prey"
};

Leaderboard lb2 = new()
{
Name = "Prey (2017)",
Slug = "prey-2017"
};

context.Leaderboards.AddRange(lb, lb2);
await context.SaveChangesAsync();
context.ChangeTracker.Clear();

await FluentActions.Awaiting(() => _apiClient.Patch(
$"/leaderboard/{lb2.Id}",
new()
{
Jwt = _jwt,
Body = new UpdateLeaderboardRequest()
{
Name = "Prey",
Slug = "prey"
}
}
)).Should().ThrowAsync<RequestFailureException>().Where(e => e.Response.StatusCode == HttpStatusCode.Conflict);

Leaderboard found = await context.Leaderboards.SingleAsync(l => l.Id == lb2.Id);
found.Should().BeEquivalentTo(lb2, config => config.Excluding(l => l.Categories));
}

[TestCase("Grand Theft Auto Five", "Grand Theft Auto V", "gtav", "")]
[TestCase("n", "N", "n-2004", "n")]
[TestCase("Super Mario Brothers", "Super Mario Bros.", "super-mario-bros", "super mario bros")]
[TestCase("Super Smash Brothers Brawl", "Super Smash Bros. Brawl", "ssbb", "super-smash-bros.-brawl")]
[TestCase(
"Dr. Langeskov, The Tiger, and The Terribly Cursed Emerald",
"Dr. Langeskov, The Tiger, and The Terribly Cursed Emerald: A Whirlwind Heist",
"dr-langeskov-the-tiger-and-the-terribly-cursed-emerald",
"dr-langeskov-the-tiger-and-the-terribly-cursed-emerald-a-whirlwind-heist-crows-crows-crows"
)]
[TestCase("The Legendary Starfy", "伝説のスタフィー", "densetsu-no-stafy", "デンセツノスタフィー")]
[TestCase("Resident Evil", "", "resident-evil", null)]
public async Task UpdateLeaderboar_BadData(string oldName, string? newName, string oldSlug, string? newSlug)
{
ApplicationContext context = _factory.Services.CreateScope().ServiceProvider.GetRequiredService<ApplicationContext>();

Leaderboard lb = new()
{
Name = oldName,
Slug = oldSlug
};

context.Leaderboards.Add(lb);
await context.SaveChangesAsync();
context.ChangeTracker.Clear();

await FluentActions.Awaiting(() => _apiClient.Patch(
$"/leaderboard/{lb.Id}",
new()
{
Jwt = _jwt,
Body = new UpdateLeaderboardRequest()
{
Name = newName,

Check warning on line 861 in LeaderboardBackend.Test/Leaderboards.cs

View workflow job for this annotation

GitHub Actions / backend-test

Possible null reference assignment.

Check warning on line 861 in LeaderboardBackend.Test/Leaderboards.cs

View workflow job for this annotation

GitHub Actions / backend-test

Possible null reference assignment.
Slug = newSlug

Check warning on line 862 in LeaderboardBackend.Test/Leaderboards.cs

View workflow job for this annotation

GitHub Actions / backend-test

Possible null reference assignment.

Check warning on line 862 in LeaderboardBackend.Test/Leaderboards.cs

View workflow job for this annotation

GitHub Actions / backend-test

Possible null reference assignment.
}
}
)).Should().ThrowAsync<RequestFailureException>().Where(e => e.Response.StatusCode == HttpStatusCode.UnprocessableContent);

Leaderboard found = await context.Leaderboards.SingleAsync(l => l.Id == lb.Id);
found.Should().BeEquivalentTo(lb, config => config.Excluding(l => l.Categories));
}

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

Leaderboard lb = new()
{
Name = "Terraria",
Slug = "terraria"
};

context.Leaderboards.Add(lb);
await context.SaveChangesAsync();
context.ChangeTracker.Clear();
Instant instant = _clock.GetCurrentInstant() - Duration.FromMinutes(1);

await FluentActions.Awaiting(() => _apiClient.Patch(
$"/leaderboard/{lb.Id}",
new()
{
Jwt = _jwt,
Body = new
{
CreatedAt = instant,
UpdatedAt = instant,
DeletedAt = instant
}
}
)).Should().ThrowAsync<RequestFailureException>().Where(e => e.Response.StatusCode == HttpStatusCode.UnprocessableContent);

Leaderboard found = await context.Leaderboards.SingleAsync(l => l.Id == lb.Id);
found.Should().BeEquivalentTo(lb, config => config.Excluding(l => l.Categories));
}

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

Leaderboard lb = new()
{
Name = "The Witcher 3",
Slug = "witcher-3-wild-hunt"
};

context.Leaderboards.Add(lb);
await context.SaveChangesAsync();
context.ChangeTracker.Clear();

UpdateLeaderboardRequest update = new()
{
Name = "The Witcher 3: Wild Hunt",
Slug = "witcher-3",
Info = "The best game evar!"
};

await _apiClient.Patch(
$"/leaderboard/{lb.Id}",
new()
{
Jwt = _jwt,
Body = update
}
);

Leaderboard found = await context.Leaderboards.SingleAsync(l => l.Id == lb.Id);
found.Should().BeEquivalentTo(update, config => config.ExcludingMissingMembers());
found.UpdatedAt.Should().NotBeNull();
found.UpdatedAt!.Value.Should().Be(_clock.GetCurrentInstant());
}

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

Leaderboard lb = new()
{
Name = "Pokemon Yellow",
Slug = "pokemon-yelow",
Info = "This info is important and shouldn't be deleted."
};

context.Leaderboards.Add(lb);
await context.SaveChangesAsync();
context.ChangeTracker.Clear();
string newSlug = "pokemon-yellow";

await _apiClient.Patch(
$"/leaderboard/{lb.Id}",
new()
{
Jwt = _jwt,
Body = new UpdateLeaderboardRequest()
{
Slug = newSlug
}
}
);

Leaderboard updated = await context.Leaderboards.SingleAsync(l => l.Id == lb.Id);
updated.UpdatedAt.Should().NotBeNull();
updated.UpdatedAt!.Value.Should().Be(_clock.GetCurrentInstant());
updated.Name.Should().Be(lb.Name);
updated.Slug.Should().Be(newSlug);
updated.Info.Should().Be(lb.Info);
}
}
1 change: 0 additions & 1 deletion LeaderboardBackend.Test/Runs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ public async Task SetUp()
{
Name = "Super Mario 64",
Slug = "super_mario_64",
Info = null
},
Jwt = _jwt,
}
Expand Down
3 changes: 3 additions & 0 deletions LeaderboardBackend.Test/TestApi/TestApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ public async Task<TResponse> Put<TResponse>(string endpoint, HttpRequestInit ini
public async Task<HttpResponseMessage> Delete(string endpoint, HttpRequestInit init) =>
await Send(endpoint, init with { Method = HttpMethod.Delete });

public async Task<HttpResponseMessage> Patch(string endpoint, HttpRequestInit init) =>
await Send(endpoint, init with { Method = HttpMethod.Patch });

private async Task<TResponse> SendAndRead<TResponse>(string endpoint, HttpRequestInit init)
{
HttpResponseMessage response = await Send(endpoint, init);
Expand Down
34 changes: 33 additions & 1 deletion LeaderboardBackend/Controllers/LeaderboardsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ long id
notFound => NotFound(),
neverDeleted =>
NotFound(ProblemDetailsFactory.CreateProblemDetails(HttpContext, 404, "Not Deleted")),
conflict => Conflict(LeaderboardViewModel.MapFrom(conflict.Board))
conflict => Conflict(LeaderboardViewModel.MapFrom(conflict.Conflicting))
);
}

Expand Down Expand Up @@ -135,4 +135,36 @@ public async Task<ActionResult> DeleteLeaderboard([FromRoute, SwaggerParameter(R
alreadyDeleted => NotFound(ProblemDetailsFactory.CreateProblemDetails(HttpContext, 404, "Already Deleted"))
);
}

[Authorize(Policy = UserTypes.ADMINISTRATOR)]
[HttpPatch("/leaderboard/{id:long}")]
[SwaggerOperation(
"Updates a leaderboard with the specified new fields. This request is restricted to administrators. " +
"This operation is atomic; if an error occurs, the leaderboard will not be updated. " +
"All fields of the request body are optional but you must specify at least one.",
OperationId = "updateLeaderboard"
)]
[SwaggerResponse(204)]
[SwaggerResponse(401)]
[SwaggerResponse(403)]
[SwaggerResponse(404, Type = typeof(ProblemDetails))]
[SwaggerResponse(
409,
"The specified slug is already in use by another leaderboard. Returns the conflicting leaderboard.",
typeof(LeaderboardViewModel)
)]
[SwaggerResponse(422, Type = typeof(ValidationProblemDetails))]
public async Task<ActionResult> UpdateLeaderboard(
[FromRoute] long id,
[FromBody, SwaggerRequestBody(Required = true)] UpdateLeaderboardRequest request
)
{
UpdateResult<Leaderboard> result = await leaderboardService.UpdateLeaderboard(id, request);

return result.Match<ActionResult>(
conflict => Conflict(conflict.Conflicting),
notfound => NotFound(),
success => NoContent()
);
}
}
Loading

0 comments on commit 36e114a

Please sign in to comment.