Skip to content

Commit 7268ee7

Browse files
Merge pull request #115 from pierrick-fonquerne/58-galerie-des-personnages
feat: galerie publique des personnages (#58)
2 parents 8692ae9 + 4e06e33 commit 7268ee7

35 files changed

+1156
-75
lines changed

src/backend/src/FantasyRealm.Api/Controllers/CharactersController.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.IdentityModel.Tokens.Jwt;
2+
using FantasyRealm.Application.Common;
23
using FantasyRealm.Application.DTOs;
34
using FantasyRealm.Application.Interfaces;
45
using Microsoft.AspNetCore.Authorization;
@@ -61,6 +62,36 @@ public async Task<IActionResult> GetMine(CancellationToken cancellationToken)
6162
return Ok(result.Value);
6263
}
6364

65+
/// <summary>
66+
/// Returns a paginated list of approved and shared characters for the public gallery.
67+
/// </summary>
68+
/// <param name="gender">Optional gender filter (Male, Female).</param>
69+
/// <param name="author">Optional author pseudo search (case-insensitive).</param>
70+
/// <param name="sort">Sort order: recent (default), oldest, nameAsc.</param>
71+
/// <param name="page">Page number (1-based, default 1).</param>
72+
/// <param name="pageSize">Items per page (default 12, max 50).</param>
73+
/// <param name="cancellationToken">Cancellation token.</param>
74+
/// <returns>A paginated list of gallery characters.</returns>
75+
/// <response code="200">Gallery characters retrieved successfully.</response>
76+
[HttpGet]
77+
[AllowAnonymous]
78+
[ProducesResponseType(typeof(PagedResponse<GalleryCharacterResponse>), StatusCodes.Status200OK)]
79+
public async Task<IActionResult> GetGallery(
80+
[FromQuery] string? gender,
81+
[FromQuery] string? author,
82+
[FromQuery] string? sort,
83+
[FromQuery] int page = 1,
84+
[FromQuery] int pageSize = 12,
85+
CancellationToken cancellationToken = default)
86+
{
87+
var result = await characterService.GetGalleryAsync(gender, author, sort, page, pageSize, cancellationToken);
88+
89+
if (result.IsFailure)
90+
return StatusCode(result.ErrorCode ?? 400, new { message = result.Error });
91+
92+
return Ok(result.Value);
93+
}
94+
6495
/// <summary>
6596
/// Returns a character by its identifier.
6697
/// Authenticated owners see all their characters. Anonymous users see only approved shared characters.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
namespace FantasyRealm.Application.Common
2+
{
3+
/// <summary>
4+
/// Represents a paginated response containing a subset of items and pagination metadata.
5+
/// </summary>
6+
/// <typeparam name="T">The type of items in the response.</typeparam>
7+
public sealed record PagedResponse<T>(
8+
IReadOnlyList<T> Items,
9+
int Page,
10+
int PageSize,
11+
int TotalCount,
12+
int TotalPages);
13+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
namespace FantasyRealm.Application.DTOs
2+
{
3+
/// <summary>
4+
/// Character data for the public gallery, including author information and appearance preview.
5+
/// </summary>
6+
public sealed record GalleryCharacterResponse(
7+
int Id,
8+
string Name,
9+
string ClassName,
10+
string Gender,
11+
string AuthorPseudo,
12+
DateTime CreatedAt,
13+
string SkinColor,
14+
string HairColor,
15+
string EyeColor,
16+
string FaceShape,
17+
string HairStyle,
18+
string EyeShape,
19+
string NoseShape,
20+
string MouthShape);
21+
}

src/backend/src/FantasyRealm.Application/Interfaces/ICharacterRepository.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using FantasyRealm.Application.DTOs;
12
using FantasyRealm.Domain.Entities;
23

34
namespace FantasyRealm.Application.Interfaces
@@ -36,5 +37,18 @@ public interface ICharacterRepository
3637
/// Checks whether a character with the given name already exists for a specific user.
3738
/// </summary>
3839
Task<bool> ExistsByNameAndUserAsync(string name, int userId, int? excludeCharacterId, CancellationToken cancellationToken);
40+
41+
/// <summary>
42+
/// Returns a paginated list of approved and shared characters for the public gallery.
43+
/// Uses server-side projection to avoid loading sensitive user data into memory.
44+
/// Supports optional filtering by gender and author pseudo, with configurable sorting.
45+
/// </summary>
46+
Task<(IReadOnlyList<GalleryCharacterResponse> Items, int TotalCount)> GetGalleryAsync(
47+
string? gender,
48+
string? authorPseudo,
49+
string sortBy,
50+
int page,
51+
int pageSize,
52+
CancellationToken cancellationToken);
3953
}
4054
}

src/backend/src/FantasyRealm.Application/Interfaces/ICharacterService.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,5 +67,22 @@ public interface ICharacterService
6767
/// <param name="cancellationToken">Cancellation token.</param>
6868
/// <returns>The updated character with toggled IsShared value.</returns>
6969
Task<Result<CharacterResponse>> ToggleShareAsync(int characterId, int userId, CancellationToken cancellationToken);
70+
71+
/// <summary>
72+
/// Returns a paginated list of approved and shared characters for the public gallery.
73+
/// </summary>
74+
/// <param name="gender">Optional gender filter (Male, Female).</param>
75+
/// <param name="authorPseudo">Optional author pseudo search (case-insensitive).</param>
76+
/// <param name="sortBy">Sort order: recent (default), oldest, nameAsc.</param>
77+
/// <param name="page">Page number (1-based).</param>
78+
/// <param name="pageSize">Number of items per page.</param>
79+
/// <param name="cancellationToken">Cancellation token.</param>
80+
Task<Result<PagedResponse<GalleryCharacterResponse>>> GetGalleryAsync(
81+
string? gender,
82+
string? authorPseudo,
83+
string? sortBy,
84+
int page,
85+
int pageSize,
86+
CancellationToken cancellationToken);
7087
}
7188
}

src/backend/src/FantasyRealm.Application/Services/CharacterService.cs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,37 @@ public async Task<Result<CharacterResponse>> ToggleShareAsync(int characterId, i
232232
return Result<CharacterResponse>.Success(MapToResponse(character, character.Class.Name, true));
233233
}
234234

235+
/// <inheritdoc />
236+
public async Task<Result<PagedResponse<GalleryCharacterResponse>>> GetGalleryAsync(
237+
string? gender,
238+
string? authorPseudo,
239+
string? sortBy,
240+
int page,
241+
int pageSize,
242+
CancellationToken cancellationToken)
243+
{
244+
if (page < 1)
245+
return Result<PagedResponse<GalleryCharacterResponse>>.Failure("Le numéro de page doit être supérieur à 0.");
246+
247+
if (page > 1000)
248+
return Result<PagedResponse<GalleryCharacterResponse>>.Failure("Le numéro de page ne peut pas dépasser 1000.");
249+
250+
pageSize = Math.Clamp(pageSize, 1, 50);
251+
252+
var allowedSortValues = new[] { "recent", "oldest", "nameAsc" };
253+
var sort = allowedSortValues.Contains(sortBy) ? sortBy! : "recent";
254+
255+
var sanitizedAuthor = SanitizeLikePattern(authorPseudo);
256+
257+
var (items, totalCount) = await characterRepository.GetGalleryAsync(
258+
gender, sanitizedAuthor, sort, page, pageSize, cancellationToken);
259+
260+
var totalPages = (int)Math.Ceiling((double)totalCount / pageSize);
261+
262+
return Result<PagedResponse<GalleryCharacterResponse>>.Success(
263+
new PagedResponse<GalleryCharacterResponse>(items, page, pageSize, totalCount, totalPages));
264+
}
265+
235266
private static CharacterResponse MapToResponse(Character character, string className, bool isOwner)
236267
{
237268
return new CharacterResponse(
@@ -273,5 +304,20 @@ private static CharacterSummaryResponse MapToSummaryResponse(Character character
273304
character.NoseShape,
274305
character.MouthShape);
275306
}
307+
308+
/// <summary>
309+
/// Escapes LIKE-pattern metacharacters (<c>%</c>, <c>_</c>, <c>\</c>) in a search term
310+
/// to prevent wildcard injection in PostgreSQL ILike queries.
311+
/// </summary>
312+
private static string? SanitizeLikePattern(string? value)
313+
{
314+
if (string.IsNullOrWhiteSpace(value))
315+
return value;
316+
317+
return value
318+
.Replace(@"\", @"\\")
319+
.Replace("%", @"\%")
320+
.Replace("_", @"\_");
321+
}
276322
}
277323
}

src/backend/src/FantasyRealm.Infrastructure/Repositories/CharacterRepository.cs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
using FantasyRealm.Application.DTOs;
12
using FantasyRealm.Application.Interfaces;
23
using FantasyRealm.Domain.Entities;
4+
using FantasyRealm.Domain.Enums;
35
using FantasyRealm.Infrastructure.Persistence;
46
using Microsoft.EntityFrameworkCore;
57

@@ -62,5 +64,55 @@ public async Task<bool> ExistsByNameAndUserAsync(string name, int userId, int? e
6264
&& (!excludeCharacterId.HasValue || c.Id != excludeCharacterId.Value),
6365
cancellationToken);
6466
}
67+
68+
/// <inheritdoc />
69+
public async Task<(IReadOnlyList<GalleryCharacterResponse> Items, int TotalCount)> GetGalleryAsync(
70+
string? gender,
71+
string? authorPseudo,
72+
string sortBy,
73+
int page,
74+
int pageSize,
75+
CancellationToken cancellationToken)
76+
{
77+
var query = context.Characters
78+
.Where(c => c.Status == CharacterStatus.Approved && c.IsShared);
79+
80+
if (!string.IsNullOrWhiteSpace(gender) && Enum.TryParse<Gender>(gender, true, out var parsedGender))
81+
query = query.Where(c => c.Gender == parsedGender);
82+
83+
if (!string.IsNullOrWhiteSpace(authorPseudo))
84+
query = query.Where(c => EF.Functions.ILike(c.User.Pseudo, $"%{authorPseudo}%"));
85+
86+
query = sortBy switch
87+
{
88+
"oldest" => query.OrderBy(c => c.CreatedAt),
89+
"nameAsc" => query.OrderBy(c => c.Name),
90+
_ => query.OrderByDescending(c => c.CreatedAt)
91+
};
92+
93+
var totalCount = await query.CountAsync(cancellationToken);
94+
95+
var items = await query
96+
.Skip((page - 1) * pageSize)
97+
.Take(pageSize)
98+
.Select(c => new GalleryCharacterResponse(
99+
c.Id,
100+
c.Name,
101+
c.Class.Name,
102+
c.Gender.ToString(),
103+
c.User.Pseudo,
104+
c.CreatedAt,
105+
c.SkinColor,
106+
c.HairColor,
107+
c.EyeColor,
108+
c.FaceShape,
109+
c.HairStyle,
110+
c.EyeShape,
111+
c.NoseShape,
112+
c.MouthShape))
113+
.ToListAsync(cancellationToken);
114+
115+
return (items, totalCount);
116+
}
65117
}
66118
}

src/backend/tests/FantasyRealm.Tests.Integration/Controllers/CharactersControllerIntegrationTests.cs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,66 @@ public async Task GetMine_ReturnsIsSharedProperty()
380380
characters![0].IsShared.Should().BeFalse();
381381
}
382382

383+
// ── Gallery ──────────────────────────────────────────────────────
384+
385+
[Fact]
386+
public async Task Gallery_ReturnsEmptyList_WhenNoSharedCharacters()
387+
{
388+
var response = await _client.GetAsync("/api/characters");
389+
390+
response.StatusCode.Should().Be(HttpStatusCode.OK);
391+
var gallery = await response.Content.ReadFromJsonAsync<GalleryResult>();
392+
gallery!.Items.Should().BeEmpty();
393+
gallery.TotalCount.Should().Be(0);
394+
}
395+
396+
[Fact]
397+
public async Task Gallery_DoesNotReturnDraftCharacters()
398+
{
399+
var token = await RegisterAndGetTokenAsync();
400+
await PostAuthenticatedAsync("/api/characters", ValidCharacterPayload("DraftHero"), token);
401+
402+
var response = await _client.GetAsync("/api/characters");
403+
404+
response.StatusCode.Should().Be(HttpStatusCode.OK);
405+
var gallery = await response.Content.ReadFromJsonAsync<GalleryResult>();
406+
gallery!.Items.Should().BeEmpty();
407+
}
408+
409+
[Fact]
410+
public async Task Gallery_ReturnsPaginationMetadata()
411+
{
412+
var response = await _client.GetAsync("/api/characters?page=1&pageSize=12");
413+
414+
response.StatusCode.Should().Be(HttpStatusCode.OK);
415+
var gallery = await response.Content.ReadFromJsonAsync<GalleryResult>();
416+
gallery!.Page.Should().Be(1);
417+
gallery.PageSize.Should().Be(12);
418+
}
419+
420+
[Fact]
421+
public async Task Gallery_WithInvalidPage_Returns400()
422+
{
423+
var response = await _client.GetAsync("/api/characters?page=0");
424+
425+
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
426+
}
427+
383428
private sealed record NameAvailabilityResult(bool Available);
429+
430+
private sealed record GalleryItem(
431+
int Id,
432+
string Name,
433+
string ClassName,
434+
string Gender,
435+
string AuthorPseudo,
436+
DateTime CreatedAt);
437+
438+
private sealed record GalleryResult(
439+
IReadOnlyList<GalleryItem> Items,
440+
int Page,
441+
int PageSize,
442+
int TotalCount,
443+
int TotalPages);
384444
}
385445
}

0 commit comments

Comments
 (0)