diff --git a/GerbilManager.Tests/GerbilProfilePhotoTests.cs b/GerbilManager.Tests/GerbilProfilePhotoTests.cs new file mode 100644 index 0000000..7a0dfa7 --- /dev/null +++ b/GerbilManager.Tests/GerbilProfilePhotoTests.cs @@ -0,0 +1,83 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.RegularExpressions; +using GerbilManagerWebAPI.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace GerbilManager.Tests; + +/// profilePhotoUrl: computed field on GerbilDto — first photo by sortOrder, or null. +public class GerbilProfilePhotoTests : IClassFixture +{ + private readonly HttpClient _client; + private readonly ApiFactory _factory; + + public GerbilProfilePhotoTests(ApiFactory factory) + { + _factory = factory; + _client = factory.CreateClient(); + } + + private static Guid ExtractId(string json) => + Guid.Parse(Regex.Match(json, "\"id\":\"([^\"]+)\"").Groups[1].Value); + + private static string? GetStr(JsonElement el, string prop) => + el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String + ? v.GetString() : null; + + private async Task CreateGerbil(string name) + { + var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new { name, gender = "female" })); + Assert.Equal(HttpStatusCode.Created, resp.StatusCode); + return ExtractId(await resp.Content.ReadAsStringAsync()); + } + + private void InsertPhotos(Guid gerbilId, params (string fileName, int sortOrder)[] photos) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + foreach (var (fn, so) in photos) + db.GerbilPhotos.Add(new GerbilPhoto + { + Id = Guid.NewGuid(), GerbilId = gerbilId, + FileName = fn, SortOrder = so, CreatedAt = DateTimeOffset.UtcNow, + }); + db.SaveChanges(); + } + + [Fact] + public async Task GetById_without_photos_returns_null_profilePhotoUrl() + { + var id = await CreateGerbil("NoPhoto"); + var json = await _client.GetStringAsync($"/gerbils/{id}"); + var el = JsonDocument.Parse(json).RootElement; + Assert.True(el.TryGetProperty("profilePhotoUrl", out var v)); + Assert.Equal(JsonValueKind.Null, v.ValueKind); + } + + [Fact] + public async Task GetById_with_photos_returns_lowest_sortOrder_url() + { + var id = await CreateGerbil("PhotoGerbil"); + InsertPhotos(id, ("second.jpg", 1), ("first.jpg", 0)); + + var json = await _client.GetStringAsync($"/gerbils/{id}"); + var el = JsonDocument.Parse(json).RootElement; + Assert.Equal("/photos/files/first.jpg", GetStr(el, "profilePhotoUrl")); + } + + [Fact] + public async Task GetList_returns_profilePhotoUrl_without_n_plus_1() + { + var id = await CreateGerbil("ListPhoto"); + InsertPhotos(id, ("list-photo.jpg", 0)); + + var json = await _client.GetStringAsync("/gerbils"); + var root = JsonDocument.Parse(json).RootElement; + var items = root.GetProperty("items").EnumerateArray().ToList(); + var gerbil = items.FirstOrDefault(x => GetStr(x, "id") == id.ToString()); + Assert.NotEqual(default, gerbil); + Assert.Equal("/photos/files/list-photo.jpg", GetStr(gerbil, "profilePhotoUrl")); + } +} diff --git a/GerbilManagerWebAPI/Dtos/ApiDtos.cs b/GerbilManagerWebAPI/Dtos/ApiDtos.cs index 983583c..ccabf05 100644 --- a/GerbilManagerWebAPI/Dtos/ApiDtos.cs +++ b/GerbilManagerWebAPI/Dtos/ApiDtos.cs @@ -29,7 +29,8 @@ namespace GerbilManagerWebAPI.Dtos List CharacterTraits, string? CharacterNote, bool? IsDeaf, - bool IsResident); + bool IsResident, + string? ProfilePhotoUrl); public record LitterDto( Guid Id, diff --git a/GerbilManagerWebAPI/Endpoints/GerbilEndpoints.cs b/GerbilManagerWebAPI/Endpoints/GerbilEndpoints.cs index b62dd8e..285e8c2 100644 --- a/GerbilManagerWebAPI/Endpoints/GerbilEndpoints.cs +++ b/GerbilManagerWebAPI/Endpoints/GerbilEndpoints.cs @@ -2,6 +2,7 @@ using GerbilManagerWebAPI.Common; using GerbilManagerWebAPI.Dtos; using GerbilManagerWebAPI.Models; using Gridify; +using Gridify.EntityFramework; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -16,8 +17,21 @@ namespace GerbilManagerWebAPI.Endpoints // GET /gerbils (Gridify: filter/order/page; e.g. status==Active, litterId==…, orderBy=name) group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) => - TypedResults.Ok(await db.Gerbils.AsNoTracking() - .ToPagedResultAsync(query, ToDto))); + { + var q = query.ToQuery(); + var paging = await db.Gerbils.AsNoTracking().GridifyAsync(q); + var ids = paging.Data.Select(g => g.Id).ToList(); + var photoMap = ids.Count == 0 ? new Dictionary() : + await db.GerbilPhotos.AsNoTracking() + .Where(p => ids.Contains(p.GerbilId)) + .GroupBy(p => p.GerbilId) + .Select(g => new { GerbilId = g.Key, FileName = g.OrderBy(p => p.SortOrder).First().FileName }) + .ToDictionaryAsync(x => x.GerbilId, x => x.FileName); + var items = paging.Data + .Select(g => ToDto(g, photoMap.TryGetValue(g.Id, out var fn) ? $"/photos/files/{fn}" : null)) + .ToList(); + return TypedResults.Ok(new PagedResult(items, paging.Count, q.Page, q.PageSize)); + }); // GET /gerbils/breeders — distinct non-empty Herkunft values for the Tiere filter dropdown group.MapGet("/breeders", async (ApplicationContext db) => @@ -30,7 +44,14 @@ namespace GerbilManagerWebAPI.Endpoints group.MapGet("/{id:guid}", async Task, NotFound>> (Guid id, ApplicationContext db) => { var g = await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id); - return g is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(g)); + if (g is null) return TypedResults.NotFound(); + var photoFileName = await db.GerbilPhotos.AsNoTracking() + .Where(p => p.GerbilId == id) + .OrderBy(p => p.SortOrder) + .Select(p => p.FileName) + .FirstOrDefaultAsync(); + var profilePhotoUrl = photoFileName != null ? $"/photos/files/{photoFileName}" : null; + return TypedResults.Ok(ToDto(g, profilePhotoUrl)); }); // POST /gerbils @@ -107,10 +128,10 @@ namespace GerbilManagerWebAPI.Endpoints g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident); } - internal static GerbilDto ToDto(Gerbil g) => new( + internal static GerbilDto ToDto(Gerbil g, string? profilePhotoUrl = null) => new( g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId, g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath, g.GoHomeDate, g.Genotype, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder, - g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident); + g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident, profilePhotoUrl); } }